Spaces:
Running
Running
| import { useStore } from '@nanostores/react'; | |
| import React, { memo, useEffect, useRef, useState } from 'react'; | |
| import { Panel, type ImperativePanelHandle } from 'react-resizable-panels'; | |
| import { IconButton } from '~/components/ui/IconButton'; | |
| import { shortcutEventEmitter } from '~/lib/hooks'; | |
| import { themeStore } from '~/lib/stores/theme'; | |
| import { workbenchStore } from '~/lib/stores/workbench'; | |
| import { classNames } from '~/utils/classNames'; | |
| import { Terminal, type TerminalRef } from './Terminal'; | |
| import { createScopedLogger } from '~/utils/logger'; | |
| const logger = createScopedLogger('Terminal'); | |
| const MAX_TERMINALS = 3; | |
| export const DEFAULT_TERMINAL_SIZE = 25; | |
| export const TerminalTabs = memo(() => { | |
| const showTerminal = useStore(workbenchStore.showTerminal); | |
| const theme = useStore(themeStore); | |
| 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 addTerminal = () => { | |
| if (terminalCount < MAX_TERMINALS) { | |
| setTerminalCount(terminalCount + 1); | |
| setActiveTerminal(terminalCount); | |
| } | |
| }; | |
| 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]); | |
| 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(); | |
| }; | |
| }, []); | |
| return ( | |
| <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" /> | |
| AI 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; | |
| logger.debug(`Starting AI terminal [${index}]`); | |
| if (index == 0) { | |
| return ( | |
| <Terminal | |
| key={index} | |
| id={`terminal_${index}`} | |
| className={classNames('h-full overflow-hidden modern-scrollbar-invert', { | |
| hidden: !isActive, | |
| })} | |
| ref={(ref) => { | |
| terminalRefs.current.push(ref); | |
| }} | |
| onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)} | |
| onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} | |
| theme={theme} | |
| /> | |
| ); | |
| } else { | |
| return ( | |
| <Terminal | |
| key={index} | |
| id={`terminal_${index}`} | |
| className={classNames('modern-scrollbar 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> | |
| ); | |
| }); | |