Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BugFix: Terminal render too many times causing performance freeze #384

Merged
merged 1 commit into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 6 additions & 172 deletions app/components/workbench/EditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
import { memo, useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import {
CodeMirrorEditor,
type EditorDocument,
Expand All @@ -9,21 +9,17 @@ import {
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 { logger, renderLogger } from '~/utils/logger';
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';
import React from 'react';
import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
import { workbenchStore } from '~/lib/stores/workbench';

interface EditorPanelProps {
files?: FileMap;
Expand All @@ -38,8 +34,6 @@ interface EditorPanelProps {
onFileReset?: () => void;
}

const MAX_TERMINALS = 3;
const DEFAULT_TERMINAL_SIZE = 25;
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;

const editorSettings: EditorSettings = { tabSize: 2 };
Expand All @@ -62,13 +56,6 @@ export const EditorPanel = memo(
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;
Expand All @@ -81,48 +68,6 @@ export const EditorPanel = memo(
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}>
Expand Down Expand Up @@ -181,118 +126,7 @@ export const EditorPanel = memo(
</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 + 1 }, (_, index) => {
const isActive = activeTerminal === index;

return (
<React.Fragment key={index}>
{index == 0 ? (
<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-textSecondary hover: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" />
Bolt Terminal
</button>
) : (
<React.Fragment>
<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}
</button>
</React.Fragment>
)}
</React.Fragment>
);
})}
{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 + 1 }, (_, index) => {
const isActive = activeTerminal === index;

if (index == 0) {
logger.info('Starting bolt terminal');

return (
<Terminal
key={index}
className={classNames('h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
theme={theme}
/>
);
}

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>
<TerminalTabs />
</PanelGroup>
);
},
Expand Down
127 changes: 65 additions & 62 deletions app/components/workbench/terminal/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,71 +16,74 @@ export interface TerminalProps {
className?: string;
theme: Theme;
readonly?: boolean;
id: string;
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} />;
}),
forwardRef<TerminalRef, TerminalProps>(
({ className, theme, readonly, id, 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.debug(`Attach [${id}]`);

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