feat(FN-1660): merge fusion/fn-1660

This commit is contained in:
gsxdsm
2026-04-14 10:13:53 -07:00
parent 76dec1e9f1
commit c7d99d2cf6
6 changed files with 434 additions and 0 deletions

View File

@@ -925,6 +925,57 @@ describe("Header", () => {
fireEvent.click(closeBtn);
expect(onSearchChange).toHaveBeenCalledWith("");
});
it("does not render mobile project switch trigger on desktop", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "desktop");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("does not render mobile project switch trigger on tablet", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "tablet");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("renders mobile project switch trigger on mobile with 2+ projects", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
});
it("does not render mobile project switch trigger on mobile with single project", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
];
renderHeader({
projects,
currentProject: projects[0],
onSelectProject: vi.fn(),
}, "mobile");
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
});
describe("Back to All Projects button", () => {

View File

@@ -254,6 +254,7 @@ export function Header({
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isMobileProjectSwitchOpen, setIsMobileProjectSwitchOpen] = useState(false);
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
@@ -262,6 +263,7 @@ export function Header({
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
const terminalSubmenuOpenRef = useRef(false);
const nodeSelectorRef = useRef<HTMLDivElement>(null);
const mobileProjectSwitchRef = useRef<HTMLDivElement>(null);
// Get remote nodes only (exclude local node type)
const remoteNodes = useMemo(() =>
@@ -372,6 +374,7 @@ export function Header({
setIsOverflowMenuOpen(false);
setIsMobileSearchOpen(false);
setIsNodeSelectorOpen(false);
setIsMobileProjectSwitchOpen(false);
}
};
@@ -379,6 +382,23 @@ export function Header({
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Close mobile project switch on outside click
useEffect(() => {
if (!isMobileProjectSwitchOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
mobileProjectSwitchRef.current &&
!mobileProjectSwitchRef.current.contains(e.target as Node)
) {
setIsMobileProjectSwitchOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isMobileProjectSwitchOpen]);
const handleMobileSearchToggle = useCallback(() => {
setIsMobileSearchOpen((prev) => !prev);
}, []);
@@ -438,6 +458,61 @@ export function Header({
<h1 className="logo">Fusion</h1>
</div>
{/* Mobile Project Switch - dropdown trigger next to logo when 2+ projects (mobile only) */}
{isMobile && projects.length > 1 && onSelectProject && (
<div className="mobile-project-switch" ref={mobileProjectSwitchRef}>
<button
className={`mobile-project-switch-trigger${isMobileProjectSwitchOpen ? " mobile-project-switch-trigger--open" : ""}`}
onClick={() => setIsMobileProjectSwitchOpen((prev) => !prev)}
title="Switch project"
aria-label="Switch project"
aria-expanded={isMobileProjectSwitchOpen}
aria-haspopup="listbox"
data-testid="mobile-project-switch-trigger"
>
<ChevronDown size={14} className={`mobile-project-switch-chevron${isMobileProjectSwitchOpen ? " mobile-project-switch-chevron--open" : ""}`} />
</button>
{isMobileProjectSwitchOpen && (
<div
className="mobile-project-switch-dropdown"
role="listbox"
aria-label="Select project"
data-testid="mobile-project-switch-dropdown"
>
{projects.map((project) => {
const isCurrent = currentProject?.id === project.id;
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
return (
<button
key={project.id}
className={`mobile-project-switch-item${isCurrent ? " mobile-project-switch-item--current" : ""}`}
onClick={() => {
onSelectProject(project);
setIsMobileProjectSwitchOpen(false);
}}
role="option"
aria-selected={isCurrent}
data-testid={`mobile-project-switch-item-${project.id}`}
>
<span
className="mobile-project-switch-dot"
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
/>
<div className="mobile-project-switch-info">
<span className="mobile-project-switch-name">{project.name}</span>
<span className="mobile-project-switch-path">
{project.path.split("/").slice(-2).join("/")}
</span>
</div>
{isCurrent && <Check size={14} className="mobile-project-switch-check" />}
</button>
);
})}
</div>
)}
</div>
)}
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (desktop only) */}
{!isCompact && projects.length >= 1 && onViewAllProjects && (
<ProjectSelector

View File

@@ -998,6 +998,176 @@ describe("Header", () => {
fireEvent.click(closeBtn);
expect(onSearchChange).toHaveBeenCalledWith("");
});
// ── Mobile Project Switch (logo-adjacent) ─────────────────────
it("renders mobile project switch trigger when 2+ projects and onSelectProject provided", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
});
it("does not render mobile project switch trigger with single project", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("does not render mobile project switch trigger when onSelectProject is not provided", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
/>
);
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
});
it("mobile project switch trigger opens dropdown when clicked", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
const trigger = screen.getByTestId("mobile-project-switch-trigger");
fireEvent.click(trigger);
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
});
it("mobile project switch dropdown shows all projects", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-item-proj_1")).toBeDefined();
expect(screen.getByTestId("mobile-project-switch-item-proj_2")).toBeDefined();
});
it("mobile project switch calls onSelectProject when project is selected", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const onSelectProject = vi.fn();
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={onSelectProject}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
fireEvent.click(screen.getByTestId("mobile-project-switch-item-proj_2"));
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
});
it("mobile project switch dropdown closes after selection", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.click(screen.getByTestId("mobile-project-switch-item-proj_2"));
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
it("mobile project switch closes on outside click", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.mouseDown(document.body);
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
it("mobile project switch closes on Escape key", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("mobile-project-switch-dropdown")).toBeNull();
});
it("mobile project switch shows current project as selected", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
const currentItem = screen.getByTestId("mobile-project-switch-item-proj_1");
expect(currentItem.getAttribute("aria-selected")).toBe("true");
const otherItem = screen.getByTestId("mobile-project-switch-item-proj_2");
expect(otherItem.getAttribute("aria-selected")).toBe("false");
});
});
// ── Project Selector ────────────────────────────────────