Files
NetMesh/components/terminalLayer/TerminalLayerWorkspaceSection.tsx

363 lines
14 KiB
TypeScript
Raw Normal View History

/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useEffect, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import {
getSidePanelLiveSnapshot,
sidePanelLiveStore,
} from '../../application/state/sidePanelLiveStore';
import { getPaneMagnificationShortcutLabel } from '../../domain/paneMagnification';
import { terminalLayerWorkspaceCtxEqual } from './terminalLayerViewMemo';
type WorkspaceContext = Record<string, any>;
function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext }) {
// Active workspace / focused session come ONLY from the live store so top-tab
// switches do not force a TerminalLayerView ctx rebuild just to flip them.
// Do not fall back to ctx.* — after memo omits activeWorkspace from equality,
// ctx can still hold a previous workspace after live has been cleared
// (workspace → solo session). Falling back would keep compose bar / resizer
// handlers bound to a stale workspace.
// Panes already derive visibility from activeTabStore themselves.
const live = useSyncExternalStore(
sidePanelLiveStore.subscribe,
sidePanelLiveStore.getSnapshot,
() => getSidePanelLiveSnapshot(false),
);
const activeWorkspace = live.activeWorkspace;
const focusedSessionId = live.focusedSessionId;
const isFocusMode = activeWorkspace?.viewMode === 'focus';
const {
workspaceInnerRef,
workspaceOuterRef,
activeTabId,
workspaceOverlayRef,
draggingSessionId,
dropHint,
setDropHint,
computeSplitHint,
handleWorkspaceDrop,
TerminalPanesHost,
sessions,
sessionHostsMap,
sessionChainHostsMap,
sessionSudoAutofillPasswordsMap,
sessionSudoAutofillCandidatesMap,
resolvedSessionHostIds,
workspaceById,
workspaceRectsById,
isTerminalLayerVisible,
magnifiedPane,
handleMagnifyTerminalPane,
handleTerminalPaneInteraction,
handleRestoreMagnifiedPane,
workspaceFocusHandlersRef,
workspaceBroadcastHandlersRef,
splitHorizontalHandlersRef,
splitVerticalHandlersRef,
resolveSessionAppearance,
hostMap,
keys,
identities,
snippets,
knownHosts,
terminalFontFamilyId,
fontSize,
terminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
terminalSettings,
hotkeyScheme,
disableTerminalFontZoom,
restoreTerminalCwd,
keyBindings,
resizing,
isComposeBarOpen,
sessionLogConfig,
sshDebugLogsEnabled,
onHotkeyAction,
handleTerminalFontSizeChange,
handleOpenSftp,
handleTerminalCwdChange,
handleTerminalTitleChange,
handleTerminalBell,
handleTerminalOutput,
handleTerminalContextReaderChange,
handleOpenScripts,
handleOpenHistory,
handleOpenSystem,
handleOpenTheme,
handleCloseSession,
handleStatusChange,
handleSessionExit,
handleTerminalDataCapture,
handleOsDetected,
handleUpdateHost,
handleAddKnownHost,
handleCommandExecuted,
handleCommandSubmitted,
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
handleBroadcastInput,
handleBroadcastInterruptPriorityChange,
handleToggleWorkspaceComposeBar,
handleSnippetExecutorChange,
handleProgrammaticCommandLogRewriteChange,
handleAddSelectionToAI,
activeResizers,
composeBarThemeColors,
findSplitNode,
handleComposeSend,
handleSnippetFromPanel,
refocusTerminalSession,
setIsComposeBarOpen,
setResizing,
TerminalComposeBar,
Array,
cn,
onStartSessionRename,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
onEndSessionDrag,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
t,
} = ctx;
const activeMagnifiedTerminal = !!activeTabId && magnifiedPane?.tabId === activeTabId
&& magnifiedPane.target.kind === 'terminal'
? magnifiedPane
: null;
const paneMagnificationShortcutLabel = getPaneMagnificationShortcutLabel(keyBindings, hotkeyScheme);
const [showMagnificationHint, setShowMagnificationHint] = useState(false);
useEffect(() => {
if (!activeMagnifiedTerminal) {
setShowMagnificationHint(false);
return undefined;
}
setShowMagnificationHint(true);
const timerId = window.setTimeout(() => setShowMagnificationHint(false), 1800);
return () => window.clearTimeout(timerId);
}, [activeMagnifiedTerminal]);
return (
<div className="flex-1 min-h-0 flex flex-col">
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
{draggingSessionId && !isFocusMode && (
<div
ref={workspaceOverlayRef}
className="absolute inset-0 z-30"
onDragOver={(e) => {
if (isFocusMode) return;
if (!e.dataTransfer.types.includes('session-id')) return;
e.preventDefault();
e.stopPropagation();
const hint = computeSplitHint(e);
setDropHint(hint);
}}
onDragLeave={(e) => {
if (!e.dataTransfer.types.includes('session-id')) return;
setDropHint(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
handleWorkspaceDrop(e);
}}
>
{dropHint && (
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
style={{
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
}}
/>
</div>
)}
</div>
)}
<TerminalPanesHost
sessions={sessions}
sessionHostsMap={sessionHostsMap}
sessionChainHostsMap={sessionChainHostsMap}
sessionSudoAutofillPasswordsMap={sessionSudoAutofillPasswordsMap}
sessionSudoAutofillCandidatesMap={sessionSudoAutofillCandidatesMap}
resolvedSessionHostIds={resolvedSessionHostIds}
workspaceById={workspaceById}
workspaceRectsById={workspaceRectsById}
isTerminalLayerVisible={isTerminalLayerVisible}
magnifiedPane={magnifiedPane}
onMagnifyTerminalPane={handleMagnifyTerminalPane}
onTerminalPaneInteraction={handleTerminalPaneInteraction}
workspaceFocusHandlersRef={workspaceFocusHandlersRef}
workspaceBroadcastHandlersRef={workspaceBroadcastHandlersRef}
splitHorizontalHandlersRef={splitHorizontalHandlersRef}
splitVerticalHandlersRef={splitVerticalHandlersRef}
resolveSessionAppearance={resolveSessionAppearance}
hostMap={hostMap}
keys={keys}
identities={identities}
snippets={snippets}
knownHosts={knownHosts}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={disableTerminalFontZoom}
restoreTerminalCwd={restoreTerminalCwd}
keyBindings={keyBindings}
isResizing={!!resizing}
isComposeBarOpen={isComposeBarOpen}
sessionLog={sessionLogConfig}
sshDebugLogEnabled={sshDebugLogsEnabled}
onHotkeyAction={onHotkeyAction}
onTerminalFontSizeChange={handleTerminalFontSizeChange}
onOpenSftp={handleOpenSftp}
onTerminalCwdChange={handleTerminalCwdChange}
onTerminalTitleChange={handleTerminalTitleChange}
onTerminalBell={handleTerminalBell}
onTerminalOutput={handleTerminalOutput}
onTerminalContextReaderChange={handleTerminalContextReaderChange}
onOpenScripts={handleOpenScripts}
onOpenHistory={handleOpenHistory}
onOpenSystem={handleOpenSystem}
onOpenTheme={handleOpenTheme}
onCloseSession={handleCloseSession}
onStatusChange={handleStatusChange}
onSessionExit={handleSessionExit}
onTerminalDataCapture={handleTerminalDataCapture}
onOsDetected={handleOsDetected}
onUpdateHost={handleUpdateHost}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleCommandExecuted}
onCommandSubmitted={handleCommandSubmitted}
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
onSplitSession={onSplitSession}
isBroadcastEnabled={isBroadcastEnabled}
onBroadcastInput={handleBroadcastInput}
onBroadcastInterruptPriorityChange={handleBroadcastInterruptPriorityChange}
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
onSnippetExecutorChange={handleSnippetExecutorChange}
onProgrammaticCommandLogRewriteChange={handleProgrammaticCommandLogRewriteChange}
onAddSelectionToAI={handleAddSelectionToAI}
onStartSessionRename={onStartSessionRename}
onRemoveSessionFromWorkspace={onRemoveSessionFromWorkspace}
onReorderTabs={onReorderTabs}
onStartSessionDrag={onStartSessionDrag}
onEndSessionDrag={onEndSessionDrag}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
canUseGlobalBroadcast={canUseGlobalBroadcast}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
/>
{!isFocusMode && activeResizers.map((handle: any) => {
const isVertical = handle.direction === 'vertical';
const left = isVertical ? handle.rect.x - 3 : handle.rect.x;
const top = isVertical ? handle.rect.y : handle.rect.y - 3;
const width = isVertical ? handle.rect.w + 6 : handle.rect.w;
const height = isVertical ? handle.rect.h : handle.rect.h + 6;
return (
<div
key={handle.id}
className={cn('absolute group', isVertical ? 'cursor-ew-resize' : 'cursor-ns-resize')}
data-section="terminal-split-resizer"
data-split-direction={handle.direction}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
zIndex: 25,
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const ws = activeWorkspace;
if (!ws) return;
const split = findSplitNode(ws.root, handle.splitId);
const childCount = split && split.type === 'split' ? split.children.length : 0;
const sizes = split && split.type === 'split' && split.sizes && split.sizes.length === childCount
? split.sizes
: Array(childCount).fill(1);
setResizing({
workspaceId: ws.id,
splitId: handle.splitId,
index: handle.index,
direction: handle.direction,
startSizes: sizes.length ? sizes : [1, 1],
startArea: handle.splitArea,
startClient: { x: e.clientX, y: e.clientY },
});
}}
>
<div
data-section="terminal-split-resizer-bar"
className={cn(
'absolute bg-border/70 group-hover:bg-primary/60 transition-colors',
isVertical ? 'w-px h-full left-1/2 -translate-x-1/2' : 'h-px w-full top-1/2 -translate-y-1/2',
)}
/>
</div>
);
})}
</div>
{activeMagnifiedTerminal && workspaceOuterRef.current && createPortal(
<>
<div
className="absolute inset-0 z-40 bg-background/55 backdrop-blur-[1px]"
aria-hidden="true"
data-section="pane-magnification-backdrop"
/>
{showMagnificationHint && (
<button
type="button"
className="absolute bottom-4 right-4 z-[60] rounded border border-border/70 bg-background/90 px-2 py-1 text-[10px] text-muted-foreground shadow-sm animate-in fade-in duration-150"
onClick={handleRestoreMagnifiedPane}
data-section="pane-magnification-hint"
aria-label={t('terminal.paneMagnification.restore')}
>
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.terminal')}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
</button>
)}
</>,
workspaceOuterRef.current,
)}
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
snippets={snippets}
onClose={() => {
setIsComposeBarOpen(false);
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
);
}
export const TerminalLayerWorkspaceSection = memo(
TerminalLayerWorkspaceSectionInner,
(prev, next) => terminalLayerWorkspaceCtxEqual(prev.ctx, next.ctx),
);
TerminalLayerWorkspaceSection.displayName = 'TerminalLayerWorkspaceSection';