Files
fusion/packages/dashboard/app/components/TerminalLauncher.tsx
gsxdsm 7034b55288 FN-6887: add footer terminal launcher
Move terminal access from the header into a footer launcher and resizable bottom panel.

- Add a TerminalLauncher footer control with dock, resize, collapse, and pop-out affordances.
- Extend TerminalModal to support bottom-panel layout, drag resizing, and persistent panel state.
- Remove terminal/script controls from header overflow while preserving compact Mailbox and tool ordering.
- Update executor footer spacing, dashboard docs, changeset, and focused terminal/header tests.

Files changed:
 .changeset/fn-6887-terminal-footer-panel.md        |   5 +
 docs/dashboard-guide.md                            |   2 +
 packages/dashboard/app/App.tsx                     |   6 +-
 .../app/__tests__/tablet-header-controls.test.tsx  |  95 +---
 .../dashboard/app/components/ExecutorStatusBar.css |   8 +
 .../dashboard/app/components/ExecutorStatusBar.tsx |  27 +-
 packages/dashboard/app/components/Header.css       |  27 -
 packages/dashboard/app/components/Header.tsx       | 549 +--------------------
 .../dashboard/app/components/TerminalLauncher.css  | 180 +++++++
 .../dashboard/app/components/TerminalLauncher.tsx  | 306 ++++++++++++
 .../dashboard/app/components/TerminalModal.css     | 117 +++++
 .../dashboard/app/components/TerminalModal.tsx     | 360 +++++++++++++-
 .../__tests__/ExecutorStatusBar.test.tsx           |  52 +-
 .../app/components/__tests__/Header.test.tsx       | 304 ++----------
 .../components/__tests__/TerminalLauncher.test.tsx |  57 +++
 .../components/__tests__/TerminalModal.test.tsx    | 111 +++++
 16 files changed, 1282 insertions(+), 924 deletions(-)

Fusion-Task-Id: FN-6887

Fusion-Task-Lineage: 755f28b1-1ba5-43f8-b13c-264e31507658
2026-06-21 22:43:34 -07:00

307 lines
13 KiB
TypeScript

import "./TerminalLauncher.css";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Loader2, Play, Settings, Terminal } from "lucide-react";
import { fetchScripts } from "../api";
interface DropdownPosition {
top: number;
left: number;
width: number;
}
export interface TerminalLauncherProps {
projectId?: string;
onToggleTerminal?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
variant?: "header" | "footer";
compact?: boolean;
}
/*
FNXC:Terminal 2026-06-21-22:05:
FN-6887 extracts the terminal launcher (icon, Terminal label, and scripts dropdown) so desktop/tablet can render the single canonical launcher in the footer status bar instead of the Header toolbar.
*/
export function TerminalLauncher({
projectId,
onToggleTerminal,
onOpenScripts,
onRunScript,
variant = "footer",
compact = false,
}: TerminalLauncherProps) {
const { t } = useTranslation("app");
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false);
const [highlightedScriptIndex, setHighlightedScriptIndex] = useState(-1);
const [scriptsDropdownPosition, setScriptsDropdownPosition] = useState<DropdownPosition | null>(null);
const splitButtonRef = useRef<HTMLDivElement>(null);
const chevronButtonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const scriptEntries = useMemo(() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)), [scripts]);
const showScriptsFooter = scriptEntries.length > 0;
const totalScriptItems = scriptEntries.length + (showScriptsFooter ? 1 : 0);
const scriptsEnabled = Boolean(onOpenScripts && onRunScript);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;
if (vv && vv.width > 0 && vv.height > 0) {
return { width: vv.width, height: vv.height, offsetTop: vv.offsetTop, offsetLeft: vv.offsetLeft };
}
return { width: window.innerWidth, height: window.innerHeight, offsetTop: 0, offsetLeft: 0 };
}, []);
const updateScriptsDropdownPosition = useCallback(() => {
const trigger = chevronButtonRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const menu = menuRef.current;
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const rootStyle = getComputedStyle(document.documentElement);
const horizontalPadding = Number.parseFloat(rootStyle.getPropertyValue("--space-md")) || 16;
const verticalPadding = horizontalPadding;
const gap = Number.parseFloat(rootStyle.getPropertyValue("--space-xs")) || 6;
const minWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-launcher-menu-min-width")) || 160;
const preferredWidth = Number.parseFloat(rootStyle.getPropertyValue("--terminal-launcher-menu-width")) || 260;
const preferredHeight = Number.parseFloat(rootStyle.getPropertyValue("--terminal-launcher-menu-height")) || 280;
const measuredWidth = menu?.offsetWidth || Math.max(rect.width, preferredWidth);
const width = Math.min(measuredWidth, Math.max(viewportWidth - horizontalPadding * 2, minWidth));
const measuredHeight = menu?.offsetHeight || preferredHeight;
const constrainedHeight = Math.min(measuredHeight, Math.max(viewportHeight - verticalPadding * 2, minWidth));
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerRight = rect.right - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const openUpward = spaceBelow < constrainedHeight && spaceAbove > spaceBelow;
const left = Math.min(
Math.max(triggerRight - width, horizontalPadding),
viewportWidth - horizontalPadding - width,
) + offsetLeft;
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - constrainedHeight - gap + offsetTop)
: Math.min(triggerBottom + gap + offsetTop, viewportHeight + offsetTop - verticalPadding - constrainedHeight);
setScriptsDropdownPosition({ top, left, width });
}, [getEffectiveViewport]);
const handleRunQuickScript = useCallback((name: string, command: string) => {
onRunScript?.(name, command);
setIsScriptsOpen(false);
setHighlightedScriptIndex(-1);
}, [onRunScript]);
const handleManageScripts = useCallback(() => {
onOpenScripts?.();
setIsScriptsOpen(false);
setHighlightedScriptIndex(-1);
}, [onOpenScripts]);
const handleScriptsDropdownKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex((prev) => (prev < totalScriptItems - 1 ? prev + 1 : 0));
break;
case "ArrowUp":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex((prev) => (prev > 0 ? prev - 1 : totalScriptItems - 1));
break;
case "Enter":
e.preventDefault();
if (highlightedScriptIndex >= 0) {
if (highlightedScriptIndex < scriptEntries.length) {
const [name, command] = scriptEntries[highlightedScriptIndex];
handleRunQuickScript(name, command);
} else if (showScriptsFooter && highlightedScriptIndex === scriptEntries.length) {
handleManageScripts();
}
}
break;
case "Home":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex(0);
break;
case "End":
e.preventDefault();
if (totalScriptItems > 0) setHighlightedScriptIndex(totalScriptItems - 1);
break;
}
}, [handleManageScripts, handleRunQuickScript, highlightedScriptIndex, scriptEntries, showScriptsFooter, totalScriptItems]);
useEffect(() => {
if (!isScriptsOpen || !scriptsEnabled) return;
let cancelled = false;
setScriptsLoading(true);
fetchScripts(projectId)
.then((data) => {
if (!cancelled) setScripts(data);
})
.catch(() => {
if (!cancelled) setScripts({});
})
.finally(() => {
if (!cancelled) setScriptsLoading(false);
});
return () => {
cancelled = true;
};
}, [isScriptsOpen, projectId, scriptsEnabled]);
useEffect(() => {
if (!isScriptsOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (splitButtonRef.current && !splitButtonRef.current.contains(e.target as Node)) {
setIsScriptsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isScriptsOpen]);
useEffect(() => {
if (!isScriptsOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsScriptsOpen(false);
chevronButtonRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isScriptsOpen]);
useEffect(() => {
if (isScriptsOpen) {
setHighlightedScriptIndex(-1);
const timeoutId = window.setTimeout(() => menuRef.current?.focus(), 0);
return () => window.clearTimeout(timeoutId);
}
setScriptsDropdownPosition(null);
}, [isScriptsOpen]);
useEffect(() => {
if (!isScriptsOpen) return;
const rafId = requestAnimationFrame(() => updateScriptsDropdownPosition());
return () => cancelAnimationFrame(rafId);
}, [isScriptsOpen, scriptsLoading, scriptEntries.length, showScriptsFooter, updateScriptsDropdownPosition]);
useEffect(() => {
if (!isScriptsOpen) return;
const handleReposition = () => updateScriptsDropdownPosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", handleReposition);
vv.addEventListener("scroll", handleReposition);
}
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
if (vv) {
vv.removeEventListener("resize", handleReposition);
vv.removeEventListener("scroll", handleReposition);
}
};
}, [isScriptsOpen, updateScriptsDropdownPosition]);
return (
<div className={`terminal-launcher terminal-launcher--${variant}${compact ? " terminal-launcher--compact" : ""}`} ref={splitButtonRef}>
<button
className="btn terminal-launcher__main"
onClick={onToggleTerminal}
title={t("header.openTerminal", "Open Terminal")}
data-testid="terminal-toggle-btn"
type="button"
>
<Terminal size={16} />
{!compact && <span className="terminal-launcher__label">{t("header.terminal", "Terminal")}</span>}
</button>
{scriptsEnabled && (
<>
<span className="terminal-launcher__divider" />
<button
ref={chevronButtonRef}
className={`btn-icon terminal-launcher__chevron${isScriptsOpen ? " btn-icon--active" : ""}`}
onClick={() => setIsScriptsOpen((prev) => !prev)}
title={t("header.scripts", "Scripts")}
aria-haspopup="listbox"
aria-expanded={isScriptsOpen}
aria-label={t("header.quickScripts", "Quick scripts")}
data-testid="scripts-btn"
type="button"
>
<ChevronDown size={12} className={`quick-scripts-dropdown__trigger-chevron${isScriptsOpen ? " rotate" : ""}`} />
</button>
{isScriptsOpen && (
<div
ref={menuRef}
tabIndex={-1}
className="quick-scripts-dropdown__menu"
role="listbox"
aria-label={t("header.scripts", "Scripts")}
onKeyDown={handleScriptsDropdownKeyDown}
data-testid="quick-scripts-dropdown"
style={scriptsDropdownPosition ? { position: "fixed", top: `${scriptsDropdownPosition.top}px`, left: `${scriptsDropdownPosition.left}px`, width: `${scriptsDropdownPosition.width}px`, right: "auto" } : undefined}
>
{scriptsLoading ? (
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
<Loader2 size={16} className="animate-spin" />
<span>{t("header.loadingScripts", "Loading scripts...")}</span>
</div>
) : scriptEntries.length === 0 ? (
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
<div className="quick-scripts-dropdown__empty-icon"><Terminal size={16} /></div>
<p>{t("header.noScriptsConfigured", "No scripts configured")}</p>
<button className="quick-scripts-dropdown__empty-action btn" onClick={handleManageScripts} type="button">
{t("header.addFirstScript", "Add your first script")}
</button>
</div>
) : (
<>
<div className="quick-scripts-dropdown__list">
{scriptEntries.map(([name, command], index) => (
<button
key={name}
className={`quick-scripts-dropdown__item ${highlightedScriptIndex === index ? "highlighted" : ""}`}
onClick={() => handleRunQuickScript(name, command)}
role="option"
aria-selected={highlightedScriptIndex === index}
data-testid={`quick-script-item-${name}`}
type="button"
>
<Play size={14} className="quick-scripts-dropdown__item-icon" />
<div className="quick-scripts-dropdown__item-info">
<span className="quick-scripts-dropdown__item-name">{name}</span>
<span className="quick-scripts-dropdown__item-command" title={command}>{command.length > 50 ? `${command.slice(0, 50)}...` : command}</span>
</div>
</button>
))}
</div>
<div className="quick-scripts-dropdown__footer">
<button
className={`quick-scripts-dropdown__manage ${showScriptsFooter && highlightedScriptIndex === scriptEntries.length ? "highlighted" : ""}`}
onClick={handleManageScripts}
data-testid="quick-scripts-manage"
type="button"
>
<Settings size={14} />
<span>{t("header.manageScripts", "Manage Scripts...")}</span>
</button>
</div>
</>
)}
</div>
)}
</>
)}
</div>
);
}