feat(FN-685): add QuickScriptsDropdown component to dashboard header
- Create QuickScriptsDropdown component with script listing, one-click execution, and keyboard navigation (arrow keys, Enter, Escape) - Replace static Scripts button in Header with dropdown menu showing all configured scripts - Add loading, empty, and error states with 'Manage Scripts...' footer link - Add full CSS styles with theme support, animations, and mobile responsiveness - Add comprehensive tests (510 lines) covering rendering, interactions, keyboard nav, and edge cases
This commit is contained in:
@@ -492,6 +492,7 @@ function AppInner() {
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onOpenAgents={handleOpenAgents}
|
||||
onOpenScripts={handleOpenScripts}
|
||||
onRunScript={handleRunScript}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
filesOpen={filesOpen}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
|
||||
import type { ProjectInfo } from "@fusion/core";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -29,6 +30,7 @@ export interface HeaderProps {
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onOpenAgents?: () => void;
|
||||
onOpenScripts?: () => void;
|
||||
onRunScript?: (name: string, command: string) => void;
|
||||
onToggleTerminal?: () => void;
|
||||
/** Opens the top-level workspace-aware file browser modal. */
|
||||
onOpenFiles?: () => void;
|
||||
@@ -76,6 +78,7 @@ export function Header({
|
||||
onOpenWorkflowSteps,
|
||||
onOpenAgents,
|
||||
onOpenScripts,
|
||||
onRunScript,
|
||||
onToggleTerminal,
|
||||
onOpenFiles,
|
||||
filesOpen,
|
||||
@@ -396,15 +399,11 @@ export function Header({
|
||||
)}
|
||||
|
||||
{/* Scripts - desktop only */}
|
||||
{!isMobile && onOpenScripts && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenScripts}
|
||||
title="Scripts"
|
||||
data-testid="scripts-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
</button>
|
||||
{!isMobile && onOpenScripts && onRunScript && (
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={onOpenScripts}
|
||||
onRunScript={onRunScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings - always inline on desktop */}
|
||||
|
||||
510
packages/dashboard/app/components/QuickScriptsDropdown.test.tsx
Normal file
510
packages/dashboard/app/components/QuickScriptsDropdown.test.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
|
||||
// Mock the API functions
|
||||
const mockFetchScripts = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
fetchScripts: () => mockFetchScripts(),
|
||||
}));
|
||||
|
||||
const mockOnOpenScripts = vi.fn();
|
||||
const mockOnRunScript = vi.fn();
|
||||
|
||||
function renderDropdown(props = {}) {
|
||||
return render(
|
||||
<QuickScriptsDropdown
|
||||
onOpenScripts={mockOnOpenScripts}
|
||||
onRunScript={mockOnRunScript}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("QuickScriptsDropdown", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the trigger button", () => {
|
||||
renderDropdown();
|
||||
expect(screen.getByTestId("scripts-btn")).toBeDefined();
|
||||
expect(screen.getByTitle("Scripts")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show dropdown menu initially", () => {
|
||||
renderDropdown();
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropdown open/close", () => {
|
||||
it("opens dropdown when trigger is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown when clicking outside", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown on Escape key", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown when trigger is clicked again", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetching and displaying scripts", () => {
|
||||
it("shows loading state while fetching", async () => {
|
||||
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
|
||||
});
|
||||
|
||||
it("fetches and displays scripts", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
test: "npm test",
|
||||
lint: "npm run lint",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
||||
expect(screen.getByTestId("quick-script-item-lint")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays script names and truncated commands", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
"long-command": "this is a very long command that should be truncated",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const item = screen.getByTestId("quick-script-item-long-command");
|
||||
expect(item.textContent).toContain("long-command");
|
||||
expect(item.textContent).toContain("this is a very long command that should be truncat...");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles short commands without truncation", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
short: "echo hi",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const item = screen.getByTestId("quick-script-item-short");
|
||||
expect(item.textContent).toContain("short");
|
||||
expect(item.textContent).toContain("echo hi");
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts scripts alphabetically", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
zebra: "echo zebra",
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByRole("option");
|
||||
expect(items[0].textContent).toContain("alpha");
|
||||
expect(items[1].textContent).toContain("beta");
|
||||
expect(items[2].textContent).toContain("zebra");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("running scripts", () => {
|
||||
it("calls onRunScript when a script is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-script-item-build"));
|
||||
|
||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
|
||||
it("closes dropdown after running script", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
test: "npm test",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-script-item-test"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("manage scripts link", () => {
|
||||
it("shows 'Manage Scripts...' link when scripts exist", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onOpenScripts when 'Manage Scripts...' is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes dropdown when 'Manage Scripts...' is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state", () => {
|
||||
it("shows empty state when no scripts configured", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("empty state shows 'Add your first script' button", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking 'Add your first script' calls onOpenScripts", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Add your first script"));
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes dropdown when empty state action is clicked", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Add your first script"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("supports ArrowDown to highlight items", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// First ArrowDown highlights first item
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
|
||||
// Second ArrowDown highlights second item
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("supports ArrowUp to highlight items", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Go to bottom first with End key
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
|
||||
// ArrowUp moves to previous item
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("wraps around with arrow keys", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// ArrowUp from start wraps to end (Manage Scripts...)
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
||||
|
||||
// ArrowDown from end wraps to start
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("runs script with Enter key", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Highlight and press Enter
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(menu, { key: "Enter" });
|
||||
|
||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
|
||||
it("opens manage scripts with Enter key on manage button", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
build: "npm run build",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Navigate to last item (Manage Scripts...) and press Enter
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
fireEvent.keyDown(menu, { key: "Enter" });
|
||||
|
||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports Home key to go to first item", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
// Go to end first
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
// Home goes to first
|
||||
fireEvent.keyDown(menu, { key: "Home" });
|
||||
|
||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
||||
});
|
||||
|
||||
it("supports End key to go to last item", async () => {
|
||||
mockFetchScripts.mockResolvedValue({
|
||||
alpha: "echo alpha",
|
||||
beta: "echo beta",
|
||||
});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
|
||||
fireEvent.keyDown(menu, { key: "End" });
|
||||
|
||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("handles fetch errors gracefully", async () => {
|
||||
mockFetchScripts.mockRejectedValue(new Error("Failed to fetch"));
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show empty state since scripts will be empty object on error
|
||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus management", () => {
|
||||
it("menu is focusable with tabIndex", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
||||
expect(menu).toHaveAttribute("tabIndex", "-1");
|
||||
});
|
||||
|
||||
it("focus moves to trigger when Escape is pressed", async () => {
|
||||
mockFetchScripts.mockResolvedValue({});
|
||||
renderDropdown();
|
||||
|
||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
// Trigger should have focus
|
||||
expect(document.activeElement).toBe(screen.getByTestId("scripts-btn"));
|
||||
});
|
||||
});
|
||||
});
|
||||
268
packages/dashboard/app/components/QuickScriptsDropdown.tsx
Normal file
268
packages/dashboard/app/components/QuickScriptsDropdown.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Terminal, Play, Settings, Loader2, ChevronDown } from "lucide-react";
|
||||
import { fetchScripts } from "../api";
|
||||
|
||||
export interface QuickScriptsDropdownProps {
|
||||
onOpenScripts: () => void;
|
||||
onRunScript: (name: string, command: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickScriptsDropdown - Dropdown for quick script execution
|
||||
*
|
||||
* Features:
|
||||
* - Dropdown trigger with Terminal icon + chevron
|
||||
* - Fetches and displays all available scripts
|
||||
* - Click to run script immediately (opens terminal)
|
||||
* - "Manage Scripts..." footer to open full modal
|
||||
* - Keyboard navigation: arrow keys, enter to run, escape to close
|
||||
* - Loading state while fetching
|
||||
* - Empty state when no scripts configured
|
||||
* - Closes on outside click
|
||||
*/
|
||||
export function QuickScriptsDropdown({
|
||||
onOpenScripts,
|
||||
onRunScript,
|
||||
}: QuickScriptsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Script entries sorted alphabetically
|
||||
const scriptEntries = useMemo(() => {
|
||||
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [scripts]);
|
||||
|
||||
// Total items for keyboard navigation (scripts + "Manage Scripts...")
|
||||
const totalItems = scriptEntries.length + 1;
|
||||
|
||||
// Fetch scripts when dropdown opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
fetchScripts()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setScripts(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setScripts({});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset highlight when dropdown opens and focus the menu
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setHighlightedIndex(-1);
|
||||
// Focus menu for keyboard navigation
|
||||
setTimeout(() => menuRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle keyboard navigation within dropdown
|
||||
const handleDropdownKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < totalItems - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : totalItems - 1
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0) {
|
||||
if (highlightedIndex < scriptEntries.length) {
|
||||
// Run script
|
||||
const [name, command] = scriptEntries[highlightedIndex];
|
||||
handleRunScript(name, command);
|
||||
} else {
|
||||
// Manage Scripts...
|
||||
handleManageScripts();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(totalItems - 1);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[highlightedIndex, totalItems, scriptEntries]
|
||||
);
|
||||
|
||||
// Toggle dropdown
|
||||
const toggleDropdown = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Handle run script
|
||||
const handleRunScript = useCallback(
|
||||
(name: string, command: string) => {
|
||||
onRunScript(name, command);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[onRunScript]
|
||||
);
|
||||
|
||||
// Handle manage scripts
|
||||
const handleManageScripts = useCallback(() => {
|
||||
onOpenScripts();
|
||||
setIsOpen(false);
|
||||
}, [onOpenScripts]);
|
||||
|
||||
return (
|
||||
<div className="quick-scripts-dropdown" ref={dropdownRef}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className={`quick-scripts-dropdown__trigger ${isOpen ? "open" : ""}`}
|
||||
onClick={toggleDropdown}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Quick scripts"
|
||||
data-testid="scripts-btn"
|
||||
title="Scripts"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`quick-scripts-dropdown__trigger-chevron ${isOpen ? "rotate" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
tabIndex={-1}
|
||||
className="quick-scripts-dropdown__menu"
|
||||
role="listbox"
|
||||
aria-label="Scripts"
|
||||
onKeyDown={handleDropdownKeyDown}
|
||||
data-testid="quick-scripts-dropdown"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="quick-scripts-dropdown__loading" data-testid="quick-scripts-loading">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>Loading scripts...</span>
|
||||
</div>
|
||||
) : scriptEntries.length === 0 ? (
|
||||
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
|
||||
<p>No scripts configured</p>
|
||||
<button
|
||||
className="quick-scripts-dropdown__empty-action"
|
||||
onClick={handleManageScripts}
|
||||
>
|
||||
Add your first script
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Script list */}
|
||||
<div className="quick-scripts-dropdown__list">
|
||||
{scriptEntries.map(([name, command], index) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`quick-scripts-dropdown__item ${
|
||||
highlightedIndex === index ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={() => handleRunScript(name, command)}
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
data-testid={`quick-script-item-${name}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="quick-scripts-dropdown__footer">
|
||||
<button
|
||||
className={`quick-scripts-dropdown__manage ${
|
||||
highlightedIndex === scriptEntries.length ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={handleManageScripts}
|
||||
data-testid="quick-scripts-manage"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>Manage Scripts...</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13072,6 +13072,204 @@ html .column.drag-over * {
|
||||
}
|
||||
}
|
||||
|
||||
/* === QuickScriptsDropdown Component === */
|
||||
.quick-scripts-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__trigger:hover,
|
||||
.quick-scripts-dropdown__trigger.open {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__trigger-chevron {
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__trigger-chevron.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
min-width: 280px;
|
||||
max-width: min(400px, 90vw);
|
||||
max-height: min(60vh, 400px);
|
||||
overflow-y: auto;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item:hover,
|
||||
.quick-scripts-dropdown__item.highlighted {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-icon {
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-command {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__footer {
|
||||
padding: var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__manage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--todo);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__manage:hover,
|
||||
.quick-scripts-dropdown__manage.highlighted {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__empty p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__empty-action {
|
||||
padding: 8px 16px;
|
||||
background: var(--todo);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__empty-action:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 640px) {
|
||||
.quick-scripts-dropdown,
|
||||
.quick-scripts-dropdown__trigger,
|
||||
.quick-scripts-dropdown__menu {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__menu {
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
|
||||
Reference in New Issue
Block a user