| 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" /> |
| 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; |
| |
| logger.debug(`Starting bolt terminal [${index}]`); |
| |
| if (index == 0) { |
| return ( |
| <Terminal |
| key={index} |
| id={`terminal_${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} |
| /> |
| ); |
| } else { |
| return ( |
| <Terminal |
| key={index} |
| id={`terminal_${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> |
| ); |
| }); |
|
|