fix: remove monorepo
This commit is contained in:
256
app/components/workbench/EditorPanel.tsx
Normal file
256
app/components/workbench/EditorPanel.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
type EditorDocument,
|
||||
type EditorSettings,
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnSaveCallback as OnEditorSave,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { PanelHeader } from '~/components/ui/PanelHeader';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import { shortcutEventEmitter } from '~/lib/hooks';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { isMobile } from '~/utils/mobile';
|
||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
import { FileTree } from './FileTree';
|
||||
import { Terminal, type TerminalRef } from './terminal/Terminal';
|
||||
|
||||
interface EditorPanelProps {
|
||||
files?: FileMap;
|
||||
unsavedFiles?: Set<string>;
|
||||
editorDocument?: EditorDocument;
|
||||
selectedFile?: string | undefined;
|
||||
isStreaming?: boolean;
|
||||
onEditorChange?: OnEditorChange;
|
||||
onEditorScroll?: OnEditorScroll;
|
||||
onFileSelect?: (value?: string) => void;
|
||||
onFileSave?: OnEditorSave;
|
||||
onFileReset?: () => void;
|
||||
}
|
||||
|
||||
const MAX_TERMINALS = 3;
|
||||
const DEFAULT_TERMINAL_SIZE = 25;
|
||||
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
|
||||
|
||||
const editorSettings: EditorSettings = { tabSize: 2 };
|
||||
|
||||
export const EditorPanel = memo(
|
||||
({
|
||||
files,
|
||||
unsavedFiles,
|
||||
editorDocument,
|
||||
selectedFile,
|
||||
isStreaming,
|
||||
onFileSelect,
|
||||
onEditorChange,
|
||||
onEditorScroll,
|
||||
onFileSave,
|
||||
onFileReset,
|
||||
}: EditorPanelProps) => {
|
||||
renderLogger.trace('EditorPanel');
|
||||
|
||||
const theme = useStore(themeStore);
|
||||
const showTerminal = useStore(workbenchStore.showTerminal);
|
||||
|
||||
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
|
||||
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const terminalToggledByShortcut = useRef(false);
|
||||
|
||||
const [activeTerminal, setActiveTerminal] = useState(0);
|
||||
const [terminalCount, setTerminalCount] = useState(1);
|
||||
|
||||
const activeFileSegments = useMemo(() => {
|
||||
if (!editorDocument) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return editorDocument.filePath.split('/');
|
||||
}, [editorDocument]);
|
||||
|
||||
const activeFileUnsaved = useMemo(() => {
|
||||
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
|
||||
}, [editorDocument, unsavedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
|
||||
terminalToggledByShortcut.current = true;
|
||||
});
|
||||
|
||||
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
|
||||
for (const ref of Object.values(terminalRefs.current)) {
|
||||
ref?.reloadStyles();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromEventEmitter();
|
||||
unsubscribeFromThemeStore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { current: terminal } = terminalPanelRef;
|
||||
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = terminal.isCollapsed();
|
||||
|
||||
if (!showTerminal && !isCollapsed) {
|
||||
terminal.collapse();
|
||||
} else if (showTerminal && isCollapsed) {
|
||||
terminal.resize(DEFAULT_TERMINAL_SIZE);
|
||||
}
|
||||
|
||||
terminalToggledByShortcut.current = false;
|
||||
}, [showTerminal]);
|
||||
|
||||
const addTerminal = () => {
|
||||
if (terminalCount < MAX_TERMINALS) {
|
||||
setTerminalCount(terminalCount + 1);
|
||||
setActiveTerminal(terminalCount);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={20} minSize={10} collapsible>
|
||||
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
|
||||
<PanelHeader>
|
||||
<div className="i-ph:tree-structure-duotone shrink-0" />
|
||||
Files
|
||||
</PanelHeader>
|
||||
<FileTree
|
||||
className="h-full"
|
||||
files={files}
|
||||
hideRoot
|
||||
unsavedFiles={unsavedFiles}
|
||||
rootFolder={WORK_DIR}
|
||||
selectedFile={selectedFile}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
|
||||
<PanelHeader className="overflow-x-auto">
|
||||
{activeFileSegments?.length && (
|
||||
<div className="flex items-center flex-1 text-sm">
|
||||
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
|
||||
{activeFileUnsaved && (
|
||||
<div className="flex gap-1 ml-auto -mr-1.5">
|
||||
<PanelHeaderButton onClick={onFileSave}>
|
||||
<div className="i-ph:floppy-disk-duotone" />
|
||||
Save
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton onClick={onFileReset}>
|
||||
<div className="i-ph:clock-counter-clockwise-duotone" />
|
||||
Reset
|
||||
</PanelHeaderButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PanelHeader>
|
||||
<div className="h-full flex-1 overflow-hidden">
|
||||
<CodeMirrorEditor
|
||||
theme={theme}
|
||||
editable={!isStreaming && editorDocument !== undefined}
|
||||
settings={editorSettings}
|
||||
doc={editorDocument}
|
||||
autoFocusOnDocumentChange={!isMobile()}
|
||||
onScroll={onEditorScroll}
|
||||
onChange={onEditorChange}
|
||||
onSave={onFileSave}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel
|
||||
ref={terminalPanelRef}
|
||||
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
|
||||
minSize={10}
|
||||
collapsible
|
||||
onExpand={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(true);
|
||||
}
|
||||
}}
|
||||
onCollapse={() => {
|
||||
if (!terminalToggledByShortcut.current) {
|
||||
workbenchStore.toggleTerminal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="bg-bolt-elements-terminals-background h-full flex flex-col">
|
||||
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
|
||||
{Array.from({ length: terminalCount }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(
|
||||
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
|
||||
{
|
||||
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
|
||||
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
|
||||
!isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => setActiveTerminal(index)}
|
||||
>
|
||||
<div className="i-ph:terminal-window-duotone text-lg" />
|
||||
Terminal {terminalCount > 1 && index + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
|
||||
<IconButton
|
||||
className="ml-auto"
|
||||
icon="i-ph:caret-down"
|
||||
title="Close"
|
||||
size="md"
|
||||
onClick={() => workbenchStore.toggleTerminal(false)}
|
||||
/>
|
||||
</div>
|
||||
{Array.from({ length: terminalCount }, (_, index) => {
|
||||
const isActive = activeTerminal === index;
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
key={index}
|
||||
className={classNames('h-full overflow-hidden', {
|
||||
hidden: !isActive,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
terminalRefs.current.push(ref);
|
||||
}}
|
||||
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
|
||||
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
148
app/components/workbench/FileBreadcrumb.tsx
Normal file
148
app/components/workbench/FileBreadcrumb.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import FileTree from './FileTree';
|
||||
|
||||
const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
|
||||
|
||||
interface FileBreadcrumbProps {
|
||||
files?: FileMap;
|
||||
pathSegments?: string[];
|
||||
onFileSelect?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
const contextMenuVariants = {
|
||||
open: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
close: {
|
||||
y: 6,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
|
||||
renderLogger.trace('FileBreadcrumb');
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
|
||||
const handleSegmentClick = (index: number) => {
|
||||
setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (
|
||||
activeIndex !== null &&
|
||||
!contextMenuRef.current?.contains(event.target as Node) &&
|
||||
!segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
|
||||
) {
|
||||
setActiveIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}, [activeIndex]);
|
||||
|
||||
if (files === undefined || pathSegments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{pathSegments.map((segment, index) => {
|
||||
const isLast = index === pathSegments.length - 1;
|
||||
|
||||
const path = pathSegments.slice(0, index).join('/');
|
||||
|
||||
if (!WORK_DIR_REGEX.test(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = activeIndex === index;
|
||||
|
||||
return (
|
||||
<div key={index} className="relative flex items-center">
|
||||
<DropdownMenu.Root open={isActive} modal={false}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<span
|
||||
ref={(ref) => (segmentRefs.current[index] = ref)}
|
||||
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
|
||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
|
||||
'text-bolt-elements-textPrimary underline': isActive,
|
||||
'pr-4': isLast,
|
||||
})}
|
||||
onClick={() => handleSegmentClick(index)}
|
||||
>
|
||||
{isLast && <div className="i-ph:file-duotone" />}
|
||||
{segment}
|
||||
</span>
|
||||
</DropdownMenu.Trigger>
|
||||
{index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="z-file-tree-breadcrumb"
|
||||
asChild
|
||||
align="start"
|
||||
side="bottom"
|
||||
avoidCollisions={false}
|
||||
>
|
||||
<motion.div
|
||||
ref={contextMenuRef}
|
||||
initial="close"
|
||||
animate="open"
|
||||
exit="close"
|
||||
variants={contextMenuVariants}
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
|
||||
<FileTree
|
||||
files={files}
|
||||
hideRoot
|
||||
rootFolder={path}
|
||||
collapsed
|
||||
allowFolderSelection
|
||||
selectedFile={`${path}/${segment}`}
|
||||
onFileSelect={(filePath) => {
|
||||
setActiveIndex(null);
|
||||
onFileSelect?.(filePath);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
|
||||
</motion.div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
409
app/components/workbench/FileTree.tsx
Normal file
409
app/components/workbench/FileTree.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('FileTree');
|
||||
|
||||
const NODE_PADDING_LEFT = 8;
|
||||
const DEFAULT_HIDDEN_FILES = [/\/node_modules\//, /\/\.next/, /\/\.astro/];
|
||||
|
||||
interface Props {
|
||||
files?: FileMap;
|
||||
selectedFile?: string;
|
||||
onFileSelect?: (filePath: string) => void;
|
||||
rootFolder?: string;
|
||||
hideRoot?: boolean;
|
||||
collapsed?: boolean;
|
||||
allowFolderSelection?: boolean;
|
||||
hiddenFiles?: Array<string | RegExp>;
|
||||
unsavedFiles?: Set<string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileTree = memo(
|
||||
({
|
||||
files = {},
|
||||
onFileSelect,
|
||||
selectedFile,
|
||||
rootFolder,
|
||||
hideRoot = false,
|
||||
collapsed = false,
|
||||
allowFolderSelection = false,
|
||||
hiddenFiles,
|
||||
className,
|
||||
unsavedFiles,
|
||||
}: Props) => {
|
||||
renderLogger.trace('FileTree');
|
||||
|
||||
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
|
||||
|
||||
const fileList = useMemo(() => {
|
||||
return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
|
||||
}, [files, rootFolder, hideRoot, computedHiddenFiles]);
|
||||
|
||||
const [collapsedFolders, setCollapsedFolders] = useState(() => {
|
||||
return collapsed
|
||||
? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
|
||||
: new Set<string>();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) {
|
||||
setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
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, collapsed]);
|
||||
|
||||
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={classNames('text-sm', className)}>
|
||||
{filteredFileList.map((fileOrFolder) => {
|
||||
switch (fileOrFolder.kind) {
|
||||
case 'file': {
|
||||
return (
|
||||
<File
|
||||
key={fileOrFolder.id}
|
||||
selected={selectedFile === fileOrFolder.fullPath}
|
||||
file={fileOrFolder}
|
||||
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
||||
onClick={() => {
|
||||
onFileSelect?.(fileOrFolder.fullPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'folder': {
|
||||
return (
|
||||
<Folder
|
||||
key={fileOrFolder.id}
|
||||
folder={fileOrFolder}
|
||||
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
|
||||
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
||||
onClick={() => {
|
||||
toggleCollapseState(fileOrFolder.fullPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default FileTree;
|
||||
|
||||
interface FolderProps {
|
||||
folder: FolderNode;
|
||||
collapsed: boolean;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function Folder({ folder: { depth, name }, collapsed, selected = false, onClick }: FolderProps) {
|
||||
return (
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
||||
!selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
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;
|
||||
unsavedChanges?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function File({ file: { depth, name }, onClick, selected, unsavedChanges = false }: FileProps) {
|
||||
return (
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault': !selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={depth}
|
||||
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
||||
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={classNames('flex items-center', {
|
||||
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 truncate pr-2">{name}</div>
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
</div>
|
||||
</NodeButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
depth: number;
|
||||
iconClasses: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function NodeButton({ depth, iconClasses, onClick, className, children }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center gap-1.5 w-full pr-2 border-2 border-transparent text-faded py-0.5',
|
||||
className,
|
||||
)}
|
||||
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
<div className={classNames('scale-120 shrink-0', iconClasses)}></div>
|
||||
<div className="truncate w-full text-left">{children}</div>
|
||||
</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 = '/',
|
||||
hideRoot: boolean,
|
||||
hiddenFiles: Array<string | RegExp>,
|
||||
): Node[] {
|
||||
const folderPaths = new Set<string>();
|
||||
const fileList: Node[] = [];
|
||||
|
||||
let defaultDepth = 0;
|
||||
|
||||
if (rootFolder === '/' && !hideRoot) {
|
||||
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) || (hideRoot && fullPath === 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 sortFileList(rootFolder, fileList, hideRoot);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the given list of nodes into a tree structure (still a flat list).
|
||||
*
|
||||
* This function organizes the nodes into a hierarchical structure based on their paths,
|
||||
* with folders appearing before files and all items sorted alphabetically within their level.
|
||||
*
|
||||
* @note This function mutates the given `nodeList` array for performance reasons.
|
||||
*
|
||||
* @param rootFolder - The path of the root folder to start the sorting from.
|
||||
* @param nodeList - The list of nodes to be sorted.
|
||||
*
|
||||
* @returns A new array of nodes sorted in depth-first order.
|
||||
*/
|
||||
function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
|
||||
logger.trace('sortFileList');
|
||||
|
||||
const nodeMap = new Map<string, Node>();
|
||||
const childrenMap = new Map<string, Node[]>();
|
||||
|
||||
// pre-sort nodes by name and type
|
||||
nodeList.sort((a, b) => compareNodes(a, b));
|
||||
|
||||
for (const node of nodeList) {
|
||||
nodeMap.set(node.fullPath, node);
|
||||
|
||||
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf('/'));
|
||||
|
||||
if (parentPath !== rootFolder.slice(0, rootFolder.lastIndexOf('/'))) {
|
||||
if (!childrenMap.has(parentPath)) {
|
||||
childrenMap.set(parentPath, []);
|
||||
}
|
||||
|
||||
childrenMap.get(parentPath)?.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedList: Node[] = [];
|
||||
|
||||
const depthFirstTraversal = (path: string): void => {
|
||||
const node = nodeMap.get(path);
|
||||
|
||||
if (node) {
|
||||
sortedList.push(node);
|
||||
}
|
||||
|
||||
const children = childrenMap.get(path);
|
||||
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
if (child.kind === 'folder') {
|
||||
depthFirstTraversal(child.fullPath);
|
||||
} else {
|
||||
sortedList.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (hideRoot) {
|
||||
// if root is hidden, start traversal from its immediate children
|
||||
const rootChildren = childrenMap.get(rootFolder) || [];
|
||||
|
||||
for (const child of rootChildren) {
|
||||
depthFirstTraversal(child.fullPath);
|
||||
}
|
||||
} else {
|
||||
depthFirstTraversal(rootFolder);
|
||||
}
|
||||
|
||||
return sortedList;
|
||||
}
|
||||
|
||||
function compareNodes(a: Node, b: Node): number {
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === 'folder' ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
83
app/components/workbench/PortDropdown.tsx
Normal file
83
app/components/workbench/PortDropdown.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import type { PreviewInfo } from '~/lib/stores/previews';
|
||||
|
||||
interface PortDropdownProps {
|
||||
activePreviewIndex: number;
|
||||
setActivePreviewIndex: (index: number) => void;
|
||||
isDropdownOpen: boolean;
|
||||
setIsDropdownOpen: (value: boolean) => void;
|
||||
setHasSelectedPreview: (value: boolean) => void;
|
||||
previews: PreviewInfo[];
|
||||
}
|
||||
|
||||
export const PortDropdown = memo(
|
||||
({
|
||||
activePreviewIndex,
|
||||
setActivePreviewIndex,
|
||||
isDropdownOpen,
|
||||
setIsDropdownOpen,
|
||||
setHasSelectedPreview,
|
||||
previews,
|
||||
}: PortDropdownProps) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// sort previews, preserving original index
|
||||
const sortedPreviews = previews
|
||||
.map((previewInfo, index) => ({ ...previewInfo, index }))
|
||||
.sort((a, b) => a.port - b.port);
|
||||
|
||||
// close dropdown if user clicks outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDropdownOpen) {
|
||||
window.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
window.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative z-port-dropdown" ref={dropdownRef}>
|
||||
<IconButton icon="i-ph:plug" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor rounded shadow-sm min-w-[140px] dropdown-animation">
|
||||
<div className="px-4 py-2 border-b border-bolt-elements-borderColor text-sm font-semibold text-bolt-elements-textPrimary">
|
||||
Ports
|
||||
</div>
|
||||
{sortedPreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.port}
|
||||
className="flex items-center px-4 py-2 cursor-pointer hover:bg-bolt-elements-item-backgroundActive"
|
||||
onClick={() => {
|
||||
setActivePreviewIndex(preview.index);
|
||||
setIsDropdownOpen(false);
|
||||
setHasSelectedPreview(true);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
activePreviewIndex === preview.index
|
||||
? 'text-bolt-elements-item-contentAccent'
|
||||
: 'text-bolt-elements-item-contentDefault group-hover:text-bolt-elements-item-contentActive'
|
||||
}
|
||||
>
|
||||
{preview.port}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
124
app/components/workbench/Preview.tsx
Normal file
124
app/components/workbench/Preview.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { PortDropdown } from './PortDropdown';
|
||||
|
||||
export const Preview = memo(() => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||
const hasSelectedPreview = useRef(false);
|
||||
const previews = useStore(workbenchStore.previews);
|
||||
const activePreview = previews[activePreviewIndex];
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreview) {
|
||||
setUrl('');
|
||||
setIframeUrl(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { baseUrl } = activePreview;
|
||||
|
||||
setUrl(baseUrl);
|
||||
setIframeUrl(baseUrl);
|
||||
}, [activePreview, iframeUrl]);
|
||||
|
||||
const validateUrl = useCallback(
|
||||
(value: string) => {
|
||||
if (!activePreview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { baseUrl } = activePreview;
|
||||
|
||||
if (value === baseUrl) {
|
||||
return true;
|
||||
} else if (value.startsWith(baseUrl)) {
|
||||
return ['/', '?', '#'].includes(value.charAt(baseUrl.length));
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[activePreview],
|
||||
);
|
||||
|
||||
const findMinPortIndex = useCallback(
|
||||
(minIndex: number, preview: { port: number }, index: number, array: { port: number }[]) => {
|
||||
return preview.port < array[minIndex].port ? index : minIndex;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// when previews change, display the lowest port if user hasn't selected a preview
|
||||
useEffect(() => {
|
||||
if (previews.length > 1 && !hasSelectedPreview.current) {
|
||||
const minPortIndex = previews.reduce(findMinPortIndex, 0);
|
||||
|
||||
setActivePreviewIndex(minPortIndex);
|
||||
}
|
||||
}, [previews]);
|
||||
|
||||
const reloadPreview = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = iframeRef.current.src;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
{isPortDropdownOpen && (
|
||||
<div className="z-iframe-overlay w-full h-full absolute" onClick={() => setIsPortDropdownOpen(false)} />
|
||||
)}
|
||||
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
||||
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
||||
<div
|
||||
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
|
||||
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent outline-none"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && validateUrl(url)) {
|
||||
setIframeUrl(url);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{previews.length > 1 && (
|
||||
<PortDropdown
|
||||
activePreviewIndex={activePreviewIndex}
|
||||
setActivePreviewIndex={setActivePreviewIndex}
|
||||
isDropdownOpen={isPortDropdownOpen}
|
||||
setHasSelectedPreview={(value) => (hasSelectedPreview.current = value)}
|
||||
setIsDropdownOpen={setIsPortDropdownOpen}
|
||||
previews={previews}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 border-t border-bolt-elements-borderColor">
|
||||
{activePreview ? (
|
||||
<iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
|
||||
) : (
|
||||
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
187
app/components/workbench/Workbench.client.tsx
Normal file
187
app/components/workbench/Workbench.client.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||
import { computed } from 'nanostores';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
} from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
||||
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
|
||||
const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
||||
left: {
|
||||
value: 'code',
|
||||
text: 'Code',
|
||||
},
|
||||
right: {
|
||||
value: 'preview',
|
||||
text: 'Preview',
|
||||
},
|
||||
};
|
||||
|
||||
const workbenchVariants = {
|
||||
closed: {
|
||||
width: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
open: {
|
||||
width: 'var(--workbench-width)',
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
const files = useStore(workbenchStore.files);
|
||||
const selectedView = useStore(workbenchStore.currentView);
|
||||
|
||||
const setSelectedView = (view: WorkbenchViewType) => {
|
||||
workbenchStore.currentView.set(view);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreview) {
|
||||
setSelectedView('preview');
|
||||
}
|
||||
}, [hasPreview]);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const onFileSave = useCallback(() => {
|
||||
workbenchStore.saveCurrentDocument().catch(() => {
|
||||
toast.error('Failed to update file content');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFileReset = useCallback(() => {
|
||||
workbenchStore.resetCurrentDocument();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
<motion.div
|
||||
initial="closed"
|
||||
animate={showWorkbench ? 'open' : 'closed'}
|
||||
variants={workbenchVariants}
|
||||
className="z-workbench"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[left,width] duration-200 bolt-ease-cubic-bezier',
|
||||
{
|
||||
'left-[var(--workbench-left)]': showWorkbench,
|
||||
'left-[100%]': !showWorkbench,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 px-6">
|
||||
<div className="h-full flex flex-col bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b border-bolt-elements-borderColor">
|
||||
<Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
onClick={() => {
|
||||
workbenchStore.toggleTerminal(!workbenchStore.showTerminal.get());
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:terminal" />
|
||||
Toggle Terminal
|
||||
</PanelHeaderButton>
|
||||
)}
|
||||
<IconButton
|
||||
icon="i-ph:x-circle"
|
||||
className="-mr-1"
|
||||
size="xl"
|
||||
onClick={() => {
|
||||
workbenchStore.showWorkbench.set(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<View
|
||||
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
>
|
||||
<EditorPanel
|
||||
editorDocument={currentDocument}
|
||||
isStreaming={isStreaming}
|
||||
selectedFile={selectedFile}
|
||||
files={files}
|
||||
unsavedFiles={unsavedFiles}
|
||||
onFileSelect={onFileSelect}
|
||||
onEditorScroll={onEditorScroll}
|
||||
onEditorChange={onEditorChange}
|
||||
onFileSave={onFileSave}
|
||||
onFileReset={onFileReset}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
>
|
||||
<Preview />
|
||||
</View>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
interface ViewProps extends HTMLMotionProps<'div'> {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
const View = memo(({ children, ...props }: ViewProps) => {
|
||||
return (
|
||||
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
86
app/components/workbench/terminal/Terminal.tsx
Normal file
86
app/components/workbench/terminal/Terminal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import type { Theme } from '~/lib/stores/theme';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { getTerminalTheme } from './theme';
|
||||
|
||||
const logger = createScopedLogger('Terminal');
|
||||
|
||||
export interface TerminalRef {
|
||||
reloadStyles: () => void;
|
||||
}
|
||||
|
||||
export interface TerminalProps {
|
||||
className?: string;
|
||||
theme: Theme;
|
||||
readonly?: boolean;
|
||||
onTerminalReady?: (terminal: XTerm) => void;
|
||||
onTerminalResize?: (cols: number, rows: number) => void;
|
||||
}
|
||||
|
||||
export const Terminal = memo(
|
||||
forwardRef<TerminalRef, TerminalProps>(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => {
|
||||
const terminalElementRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm>();
|
||||
|
||||
useEffect(() => {
|
||||
const element = terminalElementRef.current!;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
disableStdin: readonly,
|
||||
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
|
||||
fontSize: 12,
|
||||
fontFamily: 'Menlo, courier-new, courier, monospace',
|
||||
});
|
||||
|
||||
terminalRef.current = terminal;
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(element);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
onTerminalResize?.(terminal.cols, terminal.rows);
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
|
||||
logger.info('Attach terminal');
|
||||
|
||||
onTerminalReady?.(terminal);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
terminal.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current!;
|
||||
|
||||
// we render a transparent cursor in case the terminal is readonly
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
|
||||
terminal.options.disableStdin = readonly;
|
||||
}, [theme, readonly]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
reloadStyles: () => {
|
||||
const terminal = terminalRef.current!;
|
||||
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={className} ref={terminalElementRef} />;
|
||||
}),
|
||||
);
|
||||
36
app/components/workbench/terminal/theme.ts
Normal file
36
app/components/workbench/terminal/theme.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ITheme } from '@xterm/xterm';
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const cssVar = (token: string) => style.getPropertyValue(token) || undefined;
|
||||
|
||||
export function getTerminalTheme(overrides?: ITheme): ITheme {
|
||||
return {
|
||||
cursor: cssVar('--bolt-elements-terminal-cursorColor'),
|
||||
cursorAccent: cssVar('--bolt-elements-terminal-cursorColorAccent'),
|
||||
foreground: cssVar('--bolt-elements-terminal-textColor'),
|
||||
background: cssVar('--bolt-elements-terminal-backgroundColor'),
|
||||
selectionBackground: cssVar('--bolt-elements-terminal-selection-backgroundColor'),
|
||||
selectionForeground: cssVar('--bolt-elements-terminal-selection-textColor'),
|
||||
selectionInactiveBackground: cssVar('--bolt-elements-terminal-selection-backgroundColorInactive'),
|
||||
|
||||
// ansi escape code colors
|
||||
black: cssVar('--bolt-elements-terminal-color-black'),
|
||||
red: cssVar('--bolt-elements-terminal-color-red'),
|
||||
green: cssVar('--bolt-elements-terminal-color-green'),
|
||||
yellow: cssVar('--bolt-elements-terminal-color-yellow'),
|
||||
blue: cssVar('--bolt-elements-terminal-color-blue'),
|
||||
magenta: cssVar('--bolt-elements-terminal-color-magenta'),
|
||||
cyan: cssVar('--bolt-elements-terminal-color-cyan'),
|
||||
white: cssVar('--bolt-elements-terminal-color-white'),
|
||||
brightBlack: cssVar('--bolt-elements-terminal-color-brightBlack'),
|
||||
brightRed: cssVar('--bolt-elements-terminal-color-brightRed'),
|
||||
brightGreen: cssVar('--bolt-elements-terminal-color-brightGreen'),
|
||||
brightYellow: cssVar('--bolt-elements-terminal-color-brightYellow'),
|
||||
brightBlue: cssVar('--bolt-elements-terminal-color-brightBlue'),
|
||||
brightMagenta: cssVar('--bolt-elements-terminal-color-brightMagenta'),
|
||||
brightCyan: cssVar('--bolt-elements-terminal-color-brightCyan'),
|
||||
brightWhite: cssVar('--bolt-elements-terminal-color-brightWhite'),
|
||||
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user