chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:13 +08:00
commit 0878425be3
1160 changed files with 491311 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
import { ApolloProvider } from '@apollo/client/react';
import { lazy, Suspense } from 'react';
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Outlet,
Route,
RouterProvider,
} from 'react-router-dom';
import AppLayout from '@/components/layouts/app-layout';
import FlowsLayout from '@/components/layouts/flows-layout';
import MainLayout from '@/components/layouts/main-layout';
import SettingsLayout from '@/components/layouts/settings-layout';
import ProtectedRoute from '@/components/routes/protected-route';
import PublicRoute from '@/components/routes/public-route';
import { DocumentTitle } from '@/components/shared/document-title';
import PageLoader from '@/components/shared/page-loader';
import { Toaster } from '@/components/ui/sonner';
import client from '@/lib/apollo';
import { routeTitles } from '@/lib/route-titles';
import { FavoritesProvider } from '@/providers/favorites-provider';
import { FlowProvider } from '@/providers/flow-provider';
import { KnowledgesProvider } from '@/providers/knowledges-provider';
import { ProvidersProvider } from '@/providers/providers-provider';
import { ResourcesProvider } from '@/providers/resources-provider';
import { SidebarFlowsProvider } from '@/providers/sidebar-flows-provider';
import { TemplatesProvider } from '@/providers/templates-provider';
import { ThemeProvider } from '@/providers/theme-provider';
import { UserProvider } from '@/providers/user-provider';
import { SystemSettingsProvider } from './providers/system-settings-provider';
const Dashboard = lazy(() => import('@/pages/dashboard/dashboard'));
const Flow = lazy(() => import('@/pages/flows/flow'));
const FlowReport = lazy(() => import('@/pages/flows/flow-report'));
const Flows = lazy(() => import('@/pages/flows/flows'));
const NewFlow = lazy(() => import('@/pages/flows/new-flow'));
const Login = lazy(() => import('@/pages/login'));
const Knowledge = lazy(() => import('@/pages/knowledges/knowledge'));
const Knowledges = lazy(() => import('@/pages/knowledges/knowledges'));
const Resources = lazy(() => import('@/pages/resources/resources'));
const Template = lazy(() => import('@/pages/templates/template'));
const Templates = lazy(() => import('@/pages/templates/templates'));
const OAuthResult = lazy(() => import('@/pages/oauth-result'));
const SettingsAPITokens = lazy(() => import('@/pages/settings/settings-api-tokens'));
const SettingsPrompt = lazy(() => import('@/pages/settings/settings-prompt'));
const SettingsPrompts = lazy(() => import('@/pages/settings/settings-prompts'));
const SettingsProvider = lazy(() => import('@/pages/settings/settings-provider'));
const SettingsProviders = lazy(() => import('@/pages/settings/settings-providers'));
function FlowWithProvider() {
return (
<FlowProvider>
<Flow />
</FlowProvider>
);
}
function KnowledgesLayout() {
return (
<KnowledgesProvider>
<Outlet />
</KnowledgesProvider>
);
}
function ProtectedAppLayout() {
return (
<ProtectedRoute>
<SystemSettingsProvider>
<ProvidersProvider>
<SidebarFlowsProvider>
<AppLayout />
</SidebarFlowsProvider>
</ProvidersProvider>
</SystemSettingsProvider>
</ProtectedRoute>
);
}
function ProtectedReportLayout() {
return (
<ProtectedRoute>
<SystemSettingsProvider>
<FlowReport />
</SystemSettingsProvider>
</ProtectedRoute>
);
}
function PublicLoginLayout() {
return (
<PublicRoute>
<Login />
</PublicRoute>
);
}
// Root layout for the data router. Everything that previously sat between
// `<BrowserRouter>` and `<Routes>` (providers, Suspense) lives here so it has
// access to router hooks (`useNavigate`, `useLocation`, ...) while still being
// rendered under the data router. This is what enables `useBlocker` and other
// data-router-only features inside our pages.
function RootLayout() {
return (
<UserProvider>
<FavoritesProvider>
<TemplatesProvider>
<ResourcesProvider>
{/* Document <title> driven by route handles — lives in the
shell so it survives navigation between sibling detail
routes (templates/:id → templates/:id') without
flashing a generic fallback during data fetch. */}
<DocumentTitle />
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</ResourcesProvider>
</TemplatesProvider>
</FavoritesProvider>
</UserProvider>
);
}
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
{/* private routes */}
<Route element={<ProtectedAppLayout />}>
{/* Main layout for chat pages */}
<Route element={<MainLayout />}>
<Route
element={<Dashboard />}
handle={routeTitles.dashboard}
path="dashboard"
/>
{/* Flows section with FlowsProvider */}
<Route element={<FlowsLayout />}>
<Route
element={<Flows />}
handle={routeTitles.flows}
path="flows"
/>
<Route
element={<NewFlow />}
handle={routeTitles.newFlow}
path="flows/new"
/>
<Route
element={<FlowWithProvider />}
handle={routeTitles.flow}
path="flows/:flowId"
/>
</Route>
<Route
element={<Templates />}
handle={routeTitles.templates}
path="templates"
/>
<Route
element={<Template />}
handle={routeTitles.template}
path="templates/:templateId"
/>
<Route element={<KnowledgesLayout />}>
<Route
element={<Knowledges />}
handle={routeTitles.knowledges}
path="knowledges"
/>
<Route
element={<Knowledge />}
handle={routeTitles.knowledge}
path="knowledges/:knowledgeId"
/>
</Route>
<Route
element={<Resources />}
handle={routeTitles.resources}
path="resources"
/>
</Route>
{/* Settings with nested routes */}
<Route
element={<SettingsLayout />}
path="settings"
>
<Route
element={
<Navigate
replace
to="providers"
/>
}
index
/>
<Route
element={<SettingsProviders />}
handle={routeTitles.providers}
path="providers"
/>
<Route
element={<SettingsProvider />}
handle={routeTitles.provider}
path="providers/:providerId"
/>
<Route
element={<SettingsPrompts />}
handle={routeTitles.prompts}
path="prompts"
/>
<Route
element={<SettingsPrompt />}
handle={routeTitles.prompt}
path="prompts/:promptId"
/>
<Route
element={<SettingsAPITokens />}
handle={routeTitles.apiTokens}
path="api-tokens"
/>
{/* Catch-all route for unknown settings paths */}
<Route
element={
<Navigate
replace
to="/settings/providers"
/>
}
path="*"
/>
</Route>
</Route>
{/* report routes */}
<Route
element={<ProtectedReportLayout />}
handle={routeTitles.flowReport}
path="flows/:flowId/report"
/>
{/* public routes */}
<Route
element={<PublicLoginLayout />}
handle={routeTitles.login}
path="login"
/>
<Route
element={<OAuthResult />}
handle={routeTitles.oauth}
path="oauth/result"
/>
{/* other routes */}
<Route
element={<Navigate to="/dashboard" />}
path="/"
/>
<Route
element={<Navigate to="/dashboard" />}
path="*"
/>
</Route>,
),
);
function App() {
return (
<ApolloProvider client={client}>
<ThemeProvider>
<Toaster />
<RouterProvider router={router} />
</ThemeProvider>
</ApolloProvider>
);
}
export default App;
@@ -0,0 +1,58 @@
import type { ReactNode } from 'react';
import { BarChart2, Loader2 } from 'lucide-react';
import { ResponsiveContainer } from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export function ChartCard({
children,
className,
description,
empty,
height = 300,
loading,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
empty?: boolean;
height?: number;
loading?: boolean;
title: ReactNode;
}) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>
{loading ? (
<div
className="flex items-center justify-center"
style={{ height }}
>
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
) : empty ? (
<div
className="flex flex-col items-center justify-center gap-2"
style={{ height }}
>
<BarChart2 className="text-muted-foreground/30 size-10" />
<p className="text-muted-foreground text-sm">No data for this period</p>
</div>
) : (
<ResponsiveContainer
height={height}
width="100%"
>
{children}
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
import { formatNumber } from '@/lib/utils/format';
export type ChartTooltipPayloadEntry = {
color: string;
name: string;
value: number;
};
export function ChartTooltip({
active,
formatter,
label,
labelFormatter,
onFirstActive,
payload,
sessionKey,
}: {
active?: boolean;
formatter?: (value: number, name: string) => string;
label?: string;
labelFormatter?: (label: string) => string;
onFirstActive?: () => void;
payload?: Array<ChartTooltipPayloadEntry>;
sessionKey?: number;
}) {
// Store in ref to avoid stale closures in effects
const onFirstActiveRef = useRef(onFirstActive);
useEffect(() => {
onFirstActiveRef.current = onFirstActive;
}, [onFirstActive]);
const shownInSessionRef = useRef(false);
// New mouse-entry session: reset "already shown" tracking
useEffect(() => {
shownInSessionRef.current = false;
}, [sessionKey]);
// Notify parent the moment the tooltip first becomes visible in this session
useEffect(() => {
if (active && !shownInSessionRef.current) {
shownInSessionRef.current = true;
onFirstActiveRef.current?.();
}
}, [active]);
if (!active || !payload?.length) {
return null;
}
const renderedLabel = label ? (labelFormatter ? labelFormatter(label) : label) : '';
return (
<div className="bg-popover text-popover-foreground rounded-lg border px-3 py-2 shadow-md">
{renderedLabel && <p className="text-muted-foreground mb-1 text-xs">{renderedLabel}</p>}
{payload.map((entry) => (
<div
className="flex items-center gap-2 text-sm"
key={entry.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">
{formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,3 @@
export { ChartCard } from './chart-card';
export { ChartTooltip, type ChartTooltipPayloadEntry } from './chart-tooltip';
export { MetricCard } from './metric-card';
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function MetricCard({
className,
description,
icon,
loading,
title,
value,
}: {
className?: string;
description?: ReactNode;
icon?: ReactNode;
loading?: boolean;
title: ReactNode;
value: ReactNode;
}) {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{loading ? <Skeleton className="h-5 w-24" /> : title}
</CardTitle>
{loading ? <Skeleton className="size-4 shrink-0 rounded" /> : icon}
</CardHeader>
<CardContent className="flex flex-col gap-1">
{loading ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{value}</div>}
{description &&
(loading ? (
<Skeleton className="mt-1 h-3 w-32" />
) : (
<p className="text-muted-foreground text-xs">{description}</p>
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils';
interface AnthropicProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Anthropic({ className, ...props }: AnthropicProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 24 24"
{...props}
>
<title>Anthropic</title>
<path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" />
</svg>
);
}
export default Anthropic;
+26
View File
@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
interface BedrockProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Bedrock({ className, ...props }: BedrockProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 24 24"
{...props}
>
<title>Bedrock</title>
<path
clipRule="evenodd"
d="M9.973.8c.109 0 .217.03.31.089l2.69 1.673a.6.6 0 0 1 .279.506V7.4h3.455V5.824a1.897 1.897 0 0 1-.893-.625 1.905 1.905 0 0 1-.402-1.18 1.897 1.897 0 0 1 1.885-1.906c1.044 0 1.885.858 1.885 1.908 0 .843-.543 1.551-1.295 1.803v2.168l-.012.115a.589.589 0 0 1-.352.434.59.59 0 0 1-.224.047h-4.047v1.406h6.28c.113-.352.32-.666.61-.898a1.89 1.89 0 0 1 1.172-.414c1.043 0 1.887.855 1.887 1.906 0 1.05-.84 1.908-1.885 1.908a1.895 1.895 0 0 1-1.173-.414 1.895 1.895 0 0 1-.614-.9H13.25v1.537h4.852l.058.078.815 1.049c.245-.114.511-.174.783-.174 1.044 0 1.885.857 1.885 1.908 0 1.05-.841 1.906-1.885 1.906a1.896 1.896 0 0 1-1.885-1.906c0-.334.088-.647.236-.92l-.584-.754H13.25v1.406h2.88c.327.001.588.27.589.594v1.678a1.897 1.897 0 0 1 1.297 1.805l-.01.193a1.896 1.896 0 0 1-1.875 1.715 1.899 1.899 0 0 1-1.887-1.908c0-.844.544-1.558 1.297-1.809v-1.078h-2.289v4.46l-.02.151a.603.603 0 0 1-.263.36l-2.692 1.64a.586.586 0 0 1-.615-.002l-4.926-3.086a.6.6 0 0 1-.279-.507v-3.104l-2.361-1.371a.596.596 0 0 1-.295-.475l-.002-.013.002-5.149c0-.208.108-.404.289-.511l2.367-1.407v-3.04c0-.193.094-.374.25-.485l.004-.004.021-.014.006-.004L9.664.89A.585.585 0 0 1 9.973.8ZM8.13 3.233v3.059H6.95V3.963l-1.316.818v2.723l1.91 1.232L9.512 7.5V5.135h1.178V7.83a.597.597 0 0 1-.278.504l-2.25 1.414v1.867l1.5 1.065-.113.162-.445.646-.116.166-.166-.117-1.295-.92-1.414.932-.17.111-.109-.172-.422-.66-.107-.166.166-.107 1.513-.996V9.783l-1.95-1.258-2.055 1.22v1.204l1.667-1.004.174-.103.102.174.398.677.1.17-.168.102-2.273 1.369v1.85l1.962 1.14 2.17-1.304.172-.106.104.176.496.845-.168.104-2.08 1.252v2.898l1.678 1.051 2.26-1.363.173-.104.102.174.398.678.1.172-.168.101-1.739 1.047 1.536.963 2.097-1.281v-5.31L7.62 18.024l-.174.106-.101-.174-.4-.676-.102-.17 5.23-3.18V3.399L9.971 2.09 8.13 3.232Zm7.998 15.438a.717.717 0 0 0-.707.719.712.712 0 0 0 .978.664.719.719 0 0 0 .438-.662v-.004a.718.718 0 0 0-.436-.662.717.717 0 0 0-.273-.055Zm3.63-3.81a.714.714 0 0 0-.706.718c0 .4.317.72.705.72a.714.714 0 0 0 .504-.21.714.714 0 0 0 .205-.506v-.004a.716.716 0 0 0-.707-.719Zm1.557-4.989a.714.714 0 0 0-.503.211.713.713 0 0 0-.206.506c0 .398.32.72.707.72l.14-.015a.713.713 0 0 0 .567-.703.723.723 0 0 0-.05-.275.715.715 0 0 0-.64-.446l-.015.002Zm-4.021-6.57a.713.713 0 0 0-.654.445.718.718 0 0 0-.051.274l.014.146a.71.71 0 0 0 .964.518.716.716 0 0 0 .436-.663l-.012-.142a.728.728 0 0 0-.424-.524.715.715 0 0 0-.273-.054Z"
fillRule="evenodd"
/>
</svg>
);
}
export default Bedrock;
+26
View File
@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
interface CustomProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Custom({ className, ...props }: CustomProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 24 24"
{...props}
>
<title>Custom</title>
<path
clipRule="evenodd"
d="M23.252 10.365l-2.843 1.636 2.843 1.631a1.47 1.47 0 01.697.903 1.492 1.492 0 01-.15 1.135c-.202.342-.53.591-.912.693a1.498 1.498 0 01-1.132-.15l-5.09-2.924a1.473 1.473 0 01-.68-.851 1.446 1.446 0 01-.068-.485 1.5 1.5 0 01.745-1.248l5.09-2.921a1.496 1.496 0 012.044.547 1.479 1.479 0 01-.544 2.034zm-2.692 7.927l-5.087-2.92a1.477 1.477 0 00-.867-.195 1.478 1.478 0 00-.982.468c-.257.276-.4.639-.403 1.017v5.847A1.49 1.49 0 0014.718 24c.828 0 1.497-.668 1.497-1.491v-3.27l2.849 1.636a1.493 1.493 0 002.044-.544 1.49 1.49 0 00-.548-2.04zm-5.87-5.719l-2.116 2.102a.42.42 0 01-.265.112h-.621a.427.427 0 01-.265-.112l-2.115-2.102a.42.42 0 01-.11-.262v-.62a.43.43 0 01.11-.265l2.114-2.102a.426.426 0 01.264-.11h.623a.422.422 0 01.265.11l2.116 2.102a.43.43 0 01.109.265v.62a.428.428 0 01-.11.262zM13 11.99a.442.442 0 00-.113-.266l-.612-.607a.431.431 0 00-.266-.11h-.024a.426.426 0 00-.264.11l-.612.607a.436.436 0 00-.107.266v.024c0 .085.047.202.107.262l.612.61c.061.06.179.11.264.11h.024a.434.434 0 00.266-.11l.612-.61a.429.429 0 00.112-.262v-.024zM3.436 5.704l5.089 2.924c.274.157.578.219.868.195.375-.026.726-.194.983-.47.256-.275.4-.64.403-1.017V1.489C10.78.667 10.11 0 9.284 0c-.829 0-1.498.667-1.498 1.49v3.27l-2.85-1.639a1.496 1.496 0 00-2.045.546 1.489 1.489 0 00.546 2.037zm11.17 3.119c.29.024.594-.038.866-.195l5.087-2.923a1.474 1.474 0 00.697-.903 1.496 1.496 0 00-.149-1.135 1.496 1.496 0 00-2.044-.545L16.215 4.76V1.489C16.215.667 15.546 0 14.718 0c-.83 0-1.497.667-1.497 1.49v5.845a1.491 1.491 0 001.385 1.487zm-5.213 6.354a1.479 1.479 0 00-.868.194l-5.089 2.92a1.476 1.476 0 00-.696.905 1.498 1.498 0 00.148 1.135 1.496 1.496 0 002.044.543l2.851-1.636v3.27c0 .825.67 1.491 1.498 1.491.826 0 1.496-.667 1.496-1.49v-5.847a1.5 1.5 0 00-.401-1.017 1.477 1.477 0 00-.982-.468zm-1.38-2.74c.05-.156.072-.32.068-.484a1.497 1.497 0 00-.751-1.248l-5.084-2.92a1.499 1.499 0 00-2.045.547 1.481 1.481 0 00.549 2.034l2.841 1.636L.75 13.633a1.47 1.47 0 00-.698.903 1.492 1.492 0 00.15 1.135c.202.343.53.592.912.693.382.102.789.048 1.132-.15l5.086-2.924c.345-.195.577-.505.684-.852z"
fillRule="evenodd"
/>
</svg>
);
}
export default Custom;
@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils';
interface DeepSeekProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function DeepSeek({ className, ...props }: DeepSeekProps) {
return (
<svg
className={cn(className)}
viewBox="0 0 28 28"
{...props}
>
<title>DeepSeek</title>
<g transform="translate(2, 2)">
<path
d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"
fill="currentColor"
/>
</g>
</svg>
);
}
export default DeepSeek;
@@ -0,0 +1,26 @@
import { FlowStatusIcon } from '@/components/icons/flow-status-icon';
import { Badge } from '@/components/ui/badge';
import { StatusType } from '@/graphql/types';
const STATUS_LABELS: Record<StatusType, string> = {
[StatusType.Created]: 'Created',
[StatusType.Failed]: 'Failed',
[StatusType.Finished]: 'Finished',
[StatusType.Running]: 'Running',
[StatusType.Waiting]: 'Waiting',
};
export function FlowStatusBadge({ className, status }: { className?: string; status: StatusType }) {
return (
<Badge
className={className}
variant="outline"
>
<FlowStatusIcon
className="size-3"
status={status}
/>
{STATUS_LABELS[status]}
</Badge>
);
}
@@ -0,0 +1,42 @@
import type { LucideIcon } from 'lucide-react';
import { CircleCheck, CircleDashed, CircleOff, CircleX, Loader2 } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusType } from '@/graphql/types';
import { cn } from '@/lib/utils';
interface FlowStatusIconProps {
className?: string;
status?: null | StatusType | undefined;
tooltip?: string;
}
const statusIcons: Record<StatusType, { className: string; icon: LucideIcon }> = {
[StatusType.Created]: { className: 'text-blue-500', icon: CircleDashed },
[StatusType.Failed]: { className: 'text-red-500', icon: CircleX },
[StatusType.Finished]: { className: 'text-green-500', icon: CircleCheck },
[StatusType.Running]: { className: 'animate-spin text-purple-500', icon: Loader2 },
[StatusType.Waiting]: { className: 'text-yellow-500', icon: CircleDashed },
};
const defaultIcon = { className: 'text-muted-foreground', icon: CircleOff };
export function FlowStatusIcon({ className = 'size-4', status, tooltip }: FlowStatusIconProps) {
if (!status) {
return null;
}
const { className: defaultClassName, icon: Icon } = statusIcons[status] || defaultIcon;
const iconElement = <Icon className={cn('shrink-0', defaultClassName, className, tooltip && 'cursor-pointer')} />;
if (!tooltip) {
return iconElement;
}
return (
<Tooltip>
<TooltipTrigger asChild>{iconElement}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils';
interface GeminiProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Gemini({ className, ...props }: GeminiProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 16 16"
{...props}
>
<title>Gemini</title>
<path d="M16 8.016A8.522 8.522 0 008.016 16h-.032A8.521 8.521 0 000 8.016v-.032A8.521 8.521 0 007.984 0h.032A8.522 8.522 0 0016 7.984v.032z" />
</svg>
);
}
export default Gemini;
+21
View File
@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils';
interface GithubProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Github({ className, ...props }: GithubProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z" />
</svg>
);
}
export default Github;
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils';
interface GLMProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function GLM({ className, ...props }: GLMProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 24 24"
{...props}
>
<title>GLM</title>
<path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z" />
</svg>
);
}
export default GLM;
+21
View File
@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils';
interface GoogleProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Google({ className, ...props }: GoogleProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M473.16 221.48l-2.26-9.59H262.46v88.22H387c-12.93 61.4-72.93 93.72-121.94 93.72-35.66 0-73.25-15-98.13-39.11a140.08 140.08 0 01-41.8-98.88c0-37.16 16.7-74.33 41-98.78s61-38.13 97.49-38.13c41.79 0 71.74 22.19 82.94 32.31l62.69-62.36C390.86 72.72 340.34 32 261.6 32c-60.75 0-119 23.27-161.58 65.71C58 139.5 36.25 199.93 36.25 256s20.58 113.48 61.3 155.6c43.51 44.92 105.13 68.4 168.58 68.4 57.73 0 112.45-22.62 151.45-63.66 38.34-40.4 58.17-96.3 58.17-154.9 0-24.67-2.48-39.32-2.59-39.96z" />
</svg>
);
}
export default Google;
+24
View File
@@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
interface KimiProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Kimi({ className, ...props }: KimiProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 32 32"
{...props}
>
<title>MoonshotAI</title>
<g transform="translate(4, 4)">
<path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z" />
</g>
</svg>
);
}
export default Kimi;
+67
View File
@@ -0,0 +1,67 @@
import { cn } from '@/lib/utils';
interface LogoProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Logo({ className, ...props }: LogoProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
viewBox="0 0 160 160"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
clipRule="evenodd"
d="M80 24C84.4183 24 88 20.4183 88 16C88 11.5817 84.4183 8 80 8C75.5817 8 72 11.5817 72 16C72 20.4183 75.5817 24 80 24ZM80 32C88.8366 32 96 24.8366 96 16C96 7.16344 88.8366 0 80 0C71.1635 0 64 7.16344 64 16C64 24.8366 71.1635 32 80 32Z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M80 152C84.4183 152 88 148.418 88 144C88 139.582 84.4183 136 80 136C75.5817 136 72 139.582 72 144C72 148.418 75.5817 152 80 152ZM80 160C88.8366 160 96 152.837 96 144C96 135.163 88.8366 128 80 128C71.1635 128 64 135.163 64 144C64 152.837 71.1635 160 80 160Z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M31.5026 52C33.7117 48.1737 32.4007 43.2809 28.5744 41.0718C24.748 38.8627 19.8553 40.1737 17.6462 44C15.437 47.8263 16.748 52.7191 20.5744 54.9282C24.4007 57.1373 29.2934 55.8263 31.5026 52ZM38.4308 56C42.8491 48.3473 40.2271 38.5619 32.5744 34.1436C24.9217 29.7253 15.1362 32.3473 10.718 40C6.29969 47.6527 8.92169 57.4381 16.5744 61.8564C24.2271 66.2747 34.0125 63.6527 38.4308 56Z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M142.354 116C144.563 112.174 143.252 107.281 139.426 105.072C135.599 102.863 130.707 104.174 128.497 108C126.288 111.826 127.599 116.719 131.426 118.928C135.252 121.137 140.145 119.826 142.354 116ZM149.282 120C153.7 112.347 151.078 102.562 143.426 98.1436C135.773 93.7253 125.987 96.3473 121.569 104C117.151 111.653 119.773 121.438 127.426 125.856C135.078 130.275 144.864 127.653 149.282 120Z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M128.497 52C130.707 55.8263 135.599 57.1373 139.426 54.9282C143.252 52.7191 144.563 47.8263 142.354 44C140.145 40.1737 135.252 38.8627 131.426 41.0718C127.599 43.2809 126.288 48.1737 128.497 52ZM121.569 56C125.988 63.6527 135.773 66.2747 143.426 61.8564C151.078 57.4381 153.7 47.6527 149.282 40C144.864 32.3473 135.078 29.7253 127.426 34.1436C119.773 38.5619 117.151 48.3473 121.569 56Z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M17.6462 116C19.8553 119.826 24.748 121.137 28.5744 118.928C32.4007 116.719 33.7117 111.826 31.5026 108C29.2934 104.174 24.4007 102.863 20.5744 105.072C16.748 107.281 15.437 112.174 17.6462 116ZM10.718 120C15.1363 127.653 24.9217 130.275 32.5744 125.856C40.2271 121.438 42.8491 111.653 38.4308 104C34.0125 96.3473 24.2271 93.7253 16.5744 98.1436C8.9217 102.562 6.2997 112.347 10.718 120Z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M79.8564 87.8564C84.2747 87.8564 87.8564 84.2747 87.8564 79.8564C87.8564 75.4381 84.2747 71.8564 79.8564 71.8564C75.4381 71.8564 71.8564 75.4381 71.8564 79.8564C71.8564 84.2747 75.4381 87.8564 79.8564 87.8564ZM79.8564 95.8564C88.693 95.8564 95.8564 88.6929 95.8564 79.8564C95.8564 71.0198 88.693 63.8564 79.8564 63.8564C71.0198 63.8564 63.8564 71.0198 63.8564 79.8564C63.8564 88.6929 71.0198 95.8564 79.8564 95.8564Z"
fillRule="evenodd"
/>
<path d="M97.0695 21.7278C96.2011 24.3168 94.7602 26.6432 92.8972 28.5564L118.103 43.109C118.828 40.5389 120.123 38.1278 121.93 36.0812L97.0695 21.7278Z" />
<path d="M139 65.6465C136.324 66.189 133.588 66.1043 131 65.4475V94.5525C133.588 93.8957 136.324 93.811 139 94.3535V65.6465Z" />
<path d="M121.93 123.919C120.123 121.872 118.828 119.461 118.103 116.891L92.8971 131.444C94.7602 133.357 96.2011 135.683 97.0695 138.272L121.93 123.919Z" />
<path d="M62.9305 138.272C63.7989 135.683 65.2398 133.357 67.1029 131.444L41.8971 116.891C41.1717 119.461 39.8775 121.872 38.0695 123.919L62.9305 138.272Z" />
<path d="M21 94.3535C23.6764 93.811 26.4115 93.8957 29 94.5525V65.4475C26.4115 66.1043 23.6764 66.189 21 65.6465V94.3535Z" />
<path d="M38.0695 36.0812C39.8775 38.1278 41.1717 40.5389 41.8971 43.109L67.1029 28.5564C65.2398 26.6431 63.7989 24.3168 62.9305 21.7278L38.0695 36.0812Z" />
<path d="M76 33.554V62.2705C77.2424 61.9993 78.5327 61.8564 79.8564 61.8564C81.2824 61.8564 82.6697 62.0222 84 62.3356V33.554C82.7136 33.8459 81.3748 34 80 34C78.6252 34 77.2864 33.8459 76 33.554Z" />
<path d="M93.191 67.7653C94.982 69.7393 96.3407 72.1126 97.1176 74.7359L122.223 60.2411C121.327 59.2729 120.525 58.1906 119.837 57C119.15 55.8094 118.614 54.5729 118.223 53.3129L93.191 67.7653Z" />
<path d="M97.0434 85.2212C96.2297 87.8307 94.8381 90.1852 93.0184 92.135L118.223 106.687C118.614 105.427 119.15 104.191 119.837 103C120.525 101.809 121.327 100.727 122.223 99.7589L97.0434 85.2212Z" />
<path d="M84 97.3772C82.6697 97.6906 81.2824 97.8564 79.8564 97.8564C78.5327 97.8564 77.2424 97.7135 76 97.4423V126.446C77.2864 126.154 78.6252 126 80 126C81.3748 126 82.7136 126.154 84 126.446V97.3772Z" />
<path d="M66.7955 92.2424C64.9591 90.3066 63.5485 87.963 62.7138 85.3614L37.7765 99.7589C38.6726 100.727 39.4754 101.809 40.1628 103C40.8502 104.191 41.3861 105.427 41.7766 106.687L66.7955 92.2424Z" />
<path d="M62.6376 74.5946C63.4358 71.9794 64.8135 69.6169 66.621 67.6569L41.7766 53.3129C41.3861 54.5729 40.8502 55.8094 40.1628 57C39.4754 58.1906 38.6726 59.273 37.7766 60.2411L62.6376 74.5946Z" />
</svg>
);
}
export default Logo;
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils';
interface OllamaProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Ollama({ className, ...props }: OllamaProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 24 24"
{...props}
>
<title>Ollama</title>
<path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" />
</svg>
);
}
export default Ollama;
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils';
interface OpenAiProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function OpenAi({ className, ...props }: OpenAiProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 24 24"
{...props}
>
<title>OpenAI</title>
<path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" />
</svg>
);
}
export default OpenAi;
@@ -0,0 +1,63 @@
import type { ComponentType } from 'react';
import type { Provider } from '@/models/provider';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ProviderType } from '@/graphql/types';
import { cn } from '@/lib/utils';
import Anthropic from './anthropic';
import Bedrock from './bedrock';
import Custom from './custom';
import DeepSeek from './deepseek';
import Gemini from './gemini';
import GLM from './glm';
import Kimi from './kimi';
import Ollama from './ollama';
import OpenAi from './open-ai';
import Qwen from './qwen';
interface ProviderIconConfig {
className: string;
icon: ComponentType<{ className?: string }>;
}
interface ProviderIconProps {
className?: string;
provider: null | Provider | undefined;
tooltip?: string;
}
const providerIcons: Record<ProviderType, ProviderIconConfig> = {
[ProviderType.Anthropic]: { className: 'text-purple-500', icon: Anthropic },
[ProviderType.Bedrock]: { className: 'text-blue-500', icon: Bedrock },
[ProviderType.Custom]: { className: 'text-blue-500', icon: Custom },
[ProviderType.Deepseek]: { className: 'text-blue-600', icon: DeepSeek },
[ProviderType.Gemini]: { className: 'text-blue-500', icon: Gemini },
[ProviderType.Glm]: { className: 'text-violet-500', icon: GLM },
[ProviderType.Kimi]: { className: 'text-sky-500', icon: Kimi },
[ProviderType.Ollama]: { className: 'text-blue-500', icon: Ollama },
[ProviderType.Openai]: { className: 'text-blue-500', icon: OpenAi },
[ProviderType.Qwen]: { className: 'text-orange-500', icon: Qwen },
};
const defaultProviderIcon: ProviderIconConfig = { className: 'text-blue-500', icon: Custom };
export function ProviderIcon({ className = 'size-4', provider, tooltip }: ProviderIconProps) {
if (!provider?.type) {
return null;
}
const { className: defaultClassName, icon: Icon } = providerIcons[provider.type] || defaultProviderIcon;
const iconElement = <Icon className={cn('shrink-0', defaultClassName, className, tooltip && 'cursor-pointer')} />;
if (!tooltip) {
return iconElement;
}
return (
<Tooltip>
<TooltipTrigger asChild>{iconElement}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '@/lib/utils';
interface QwenProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
function Qwen({ className, ...props }: QwenProps) {
return (
<svg
className={cn(className)}
fill="currentColor"
fillRule="evenodd"
viewBox="0 0 24 24"
{...props}
>
<title>Qwen</title>
<path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" />
</svg>
);
}
export default Qwen;
@@ -0,0 +1,7 @@
import { Outlet } from 'react-router-dom';
function AppLayout() {
return <Outlet />;
}
export default AppLayout;
@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import { FlowsProvider } from '@/providers/flows-provider';
function FlowsLayout() {
return (
<FlowsProvider>
<Outlet />
</FlowsProvider>
);
}
export default FlowsLayout;
@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom';
import { MainSidebar } from '@/components/layouts/main-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
function MainLayout() {
return (
<SidebarProvider>
<MainSidebar />
<SidebarInset>
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}
export default MainLayout;
@@ -0,0 +1,437 @@
import { Avatar, AvatarFallback } from '@radix-ui/react-avatar';
import {
ChevronsUpDown,
Clock,
FileText,
Folder,
GitFork,
KeyRound,
LayoutDashboard,
LibraryBig,
LogOut,
Monitor,
Moon,
Plus,
Settings,
Settings2,
Star,
Sun,
UserIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link, useMatch, useParams } from 'react-router-dom';
import type { Flow } from '@/providers/sidebar-flows-provider';
import type { Theme } from '@/providers/theme-provider';
import Logo from '@/components/icons/logo';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from '@/components/ui/sidebar';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PasswordChangeForm } from '@/features/authentication/password-change-form';
import { useResourcesUpload } from '@/features/resources/use-resources-upload';
import { useTheme } from '@/hooks/use-theme';
import { useFavorites } from '@/providers/favorites-provider';
import { useSidebarFlows } from '@/providers/sidebar-flows-provider';
import { useUser } from '@/providers/user-provider';
interface FlowMenuItemProps {
activeFlowId: null | number;
flow: Flow;
isFavorite: boolean;
onToggleFavorite: (flowId: string) => void;
}
export function MainSidebar() {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const isDashboardActive = useMatch('/dashboard');
const isFlowsActive = useMatch('/flows/*');
const isTemplatesActive = useMatch('/templates/*');
const isKnowledgesActive = useMatch('/knowledges/*');
const isResourcesActive = useMatch('/resources/*');
const isSettingsActive = useMatch('/settings/*');
const { flowId: flowIdParam } = useParams<{ flowId: string }>();
const { authInfo, logout } = useUser();
const user = authInfo?.user;
const { setTheme, theme } = useTheme();
const { addFavoriteFlow, favoriteFlowIds, removeFavoriteFlow } = useFavorites();
const { flows } = useSidebarFlows();
const resourcesUpload = useResourcesUpload();
const flowId = useMemo(() => (flowIdParam ? Number(flowIdParam) : null), [flowIdParam]);
const favoriteFlows = useMemo(
() =>
flows
.filter((flow) => favoriteFlowIds.includes(Number(flow.id)))
.sort((a, b) => Number(b.id) - Number(a.id)),
[flows, favoriteFlowIds],
);
const recentFlows = useMemo(
() =>
flows
.filter((flow) => !favoriteFlowIds.includes(Number(flow.id)))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5),
[flows, favoriteFlowIds],
);
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<div className="flex aspect-square size-8 items-center justify-center">
<Logo className="hover:animate-logo-spin size-6" />
</div>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-semibold">PentAGI</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="bg-sidebar sticky top-0 z-10">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem className="group-data-[state=expanded]:hidden">
<SidebarMenuButton asChild>
<Link to="/flows/new">
<Plus />
New Flow
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isDashboardActive}
>
<Link to="/dashboard">
<LayoutDashboard />
Dashboard
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isFlowsActive}
>
<Link to="/flows">
<GitFork />
Flows
</Link>
</SidebarMenuButton>
<SidebarMenuAction
asChild
className="data-[state=open]:bg-accent rounded-sm"
showOnHover
>
<Link to="/flows/new">
<Plus />
</Link>
</SidebarMenuAction>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isTemplatesActive}
>
<Link to="/templates">
<FileText />
Templates
</Link>
</SidebarMenuButton>
<SidebarMenuAction
asChild
className="data-[state=open]:bg-accent rounded-sm"
showOnHover
>
<Link to="/templates/new">
<Plus />
</Link>
</SidebarMenuAction>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isResourcesActive}
>
<Link to="/resources">
<Folder />
Resources
</Link>
</SidebarMenuButton>
<SidebarMenuAction
className="data-[state=open]:bg-accent rounded-sm"
onClick={resourcesUpload.openFilePicker}
showOnHover
title="Upload file"
type="button"
>
<Plus />
</SidebarMenuAction>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isKnowledgesActive}
>
<Link to="/knowledges">
<LibraryBig />
Knowledges
</Link>
</SidebarMenuButton>
<SidebarMenuAction
asChild
className="data-[state=open]:bg-accent rounded-sm"
showOnHover
>
<Link to="/knowledges/new">
<Plus />
</Link>
</SidebarMenuAction>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{recentFlows.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-2">
<Clock />
Recent Flows
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{recentFlows.map((flow) => (
<FlowMenuItem
activeFlowId={flowId}
flow={flow}
isFavorite={false}
key={flow.id}
onToggleFavorite={addFavoriteFlow}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{favoriteFlows.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-2">
<Star />
Favorite Flows
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{favoriteFlows.map((flow) => (
<FlowMenuItem
activeFlowId={flowId}
flow={flow}
isFavorite
key={flow.id}
onToggleFavorite={removeFavoriteFlow}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={!!isSettingsActive}
>
<Link to="/settings">
<Settings />
Settings
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
size="lg"
>
<Avatar className="bg-background dark:bg-muted size-8 rounded-lg">
<AvatarFallback className="flex size-8 items-center justify-center">
<UserIcon className="size-4" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user?.name}</span>
<span className="truncate text-xs">{user?.mail}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="bottom"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="bg-muted flex size-8 items-center justify-center rounded-lg">
<AvatarFallback className="flex items-center justify-center rounded-lg">
<UserIcon className="size-4" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user?.name}</span>
<span className="truncate text-xs">{user?.mail}</span>
<span className="text-muted-foreground truncate text-xs">
{user?.type === 'local' ? 'local' : 'oauth'}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<Settings2 />
Theme
<Tabs
className="-my-1.5 -mr-2 ml-auto"
onValueChange={(value) => setTheme(value as Theme)}
value={theme || 'system'}
>
<TabsList className="h-7 p-0.5">
<TabsTrigger
aria-label="System theme"
className="h-6 px-2"
value="system"
>
<Monitor className="size-4" />
</TabsTrigger>
<TabsTrigger
aria-label="Light theme"
className="h-6 px-2"
value="light"
>
<Sun className="size-4" />
</TabsTrigger>
<TabsTrigger
aria-label="Dark theme"
className="h-6 px-2"
value="dark"
>
<Moon className="size-4" />
</TabsTrigger>
</TabsList>
</Tabs>
</DropdownMenuItem>
{user?.type === 'local' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setIsPasswordModalOpen(true)}>
<KeyRound className="mr-2 size-4" />
Change Password
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="mr-2 size-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
<input
aria-hidden="true"
className="hidden"
key={resourcesUpload.fileInputKey}
multiple
name="resource-upload"
tabIndex={-1}
type="file"
{...resourcesUpload.fileInputProps}
/>
<Dialog
onOpenChange={setIsPasswordModalOpen}
open={isPasswordModalOpen}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Change Password</DialogTitle>
</DialogHeader>
<PasswordChangeForm
onCancel={() => setIsPasswordModalOpen(false)}
onSuccess={() => setIsPasswordModalOpen(false)}
/>
</DialogContent>
</Dialog>
</Sidebar>
);
}
function FlowMenuItem({ activeFlowId, flow, isFavorite, onToggleFavorite }: FlowMenuItemProps) {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={activeFlowId === Number(flow.id)}
>
<Link to={`/flows/${flow.id}`}>
<span className="-mx-2 w-8 shrink-0 text-center text-xs group-data-[state=expanded]:hidden">
{flow.id}
</span>
<span className="text-muted-foreground bg-background dark:bg-muted -my-0.5 -ml-0.5 h-5 min-w-5 shrink-0 rounded-md px-px py-0.5 text-center text-xs group-data-[state=collapsed]:hidden">
{flow.id}
</span>
<span className="truncate">{flow.title}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuAction
aria-label="Toggle favorite"
aria-pressed={isFavorite}
className="data-[state=open]:bg-accent rounded-sm"
onClick={() => onToggleFavorite(flow.id)}
showOnHover
>
<Star className={isFavorite ? 'fill-yellow-500 stroke-yellow-500' : ''} />
</SidebarMenuAction>
</SidebarMenuItem>
);
}
@@ -0,0 +1,170 @@
import { ArrowLeft, FileText, Key, Plug, Settings as SettingsIcon } from 'lucide-react';
import { useMemo } from 'react';
import { NavLink, Outlet, useLocation, useParams } from 'react-router-dom';
import { Separator } from '@/components/ui/separator';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar';
export interface MenuItem {
icon?: React.ReactNode;
id: string;
isActive?: boolean;
path: string;
title: string;
}
interface SettingsSidebarMenuItemProps {
item: MenuItem;
}
const menuItems: readonly MenuItem[] = [
{
icon: <Plug className="size-4" />,
id: 'providers',
path: '/settings/providers',
title: 'Providers',
},
{
icon: <FileText className="size-4" />,
id: 'prompts',
path: '/settings/prompts',
title: 'Prompts',
},
{
icon: <Key className="size-4" />,
id: 'api-tokens',
path: '/settings/api-tokens',
title: 'API Tokens',
},
] as const;
function SettingsHeader() {
const location = useLocation();
const params = useParams();
const title = useMemo(() => {
const path = location.pathname;
if (path === '/settings/providers/new') {
return 'Create Provider';
}
if (path.startsWith('/settings/providers/') && params.providerId && params.providerId !== 'new') {
return 'Edit Provider';
}
if (path === '/settings/prompts/new') {
return 'Create Prompt';
}
if (path.startsWith('/settings/prompts/') && params.promptId && params.promptId !== 'new') {
return 'Edit Prompt';
}
const activeItem = menuItems.find((item) => path.startsWith(item.path));
return activeItem?.title ?? 'Settings';
}, [location.pathname, params]);
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator
className="mr-2 h-4"
orientation="vertical"
/>
<h1 className="text-lg font-semibold">{title}</h1>
</header>
);
}
function SettingsLayout() {
return (
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden">
<SettingsSidebar />
<SidebarInset className="flex flex-1 flex-col">
<SettingsHeader />
<main className="min-h-0 flex-1 overflow-auto p-4">
<Outlet />
</main>
</SidebarInset>
</div>
</SidebarProvider>
);
}
function SettingsSidebar() {
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<div className="flex aspect-square size-8 items-center justify-center">
<SettingsIcon className="size-6" />
</div>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-semibold">Settings</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menuItems.map((item) => (
<SettingsSidebarMenuItem
item={item}
key={item.id}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenuButton asChild>
<NavLink to="/flows">
<ArrowLeft className="size-4" />
Back to App
</NavLink>
</SidebarMenuButton>
</SidebarFooter>
</Sidebar>
);
}
function SettingsSidebarMenuItem({ item }: SettingsSidebarMenuItemProps) {
const location = useLocation();
const isActive = location.pathname.startsWith(item.path);
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={isActive}
>
<NavLink to={item.path}>
{item.icon}
{item.title}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
export default SettingsLayout;
@@ -0,0 +1,29 @@
import * as React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { getReturnUrlParam } from '@/lib/utils/auth';
import { useUser } from '@/providers/user-provider';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const location = useLocation();
const { isAuthenticated, isLoading } = useUser();
if (isLoading) {
return null;
}
if (!isAuthenticated()) {
const returnParam = getReturnUrlParam(location.pathname);
return (
<Navigate
replace
to={`/login${returnParam}`}
/>
);
}
return children;
}
export default ProtectedRoute;
@@ -0,0 +1,45 @@
import * as React from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { getSafeReturnUrl } from '@/lib/utils/auth';
import { useUser } from '@/providers/user-provider';
function PublicRoute({ children }: { children: React.ReactNode }) {
const [searchParams] = useSearchParams();
const { authInfo, isAuthenticated, isLoading } = useUser();
if (isLoading) {
return null;
}
if (isAuthenticated()) {
// Only show password change form if the user is ACTUALLY authenticated
// with a valid, non-expired session. Do NOT rely solely on authInfo presence in
// memory, because clearAuth() is async and during race conditions (e.g., when
// session expires and user refreshes the page) the old authInfo may still be in
// state while localStorage is already cleared.
//
// Additional safety check: verify that authInfo.type is 'user', not 'guest'.
// If server returned guest status, we should NOT show password change form.
if (
authInfo?.user?.password_change_required &&
authInfo?.type === 'user' &&
authInfo?.user?.type === 'local' // Only local users have password_change_required
) {
return children;
}
const returnUrl = getSafeReturnUrl(searchParams.get('returnUrl'), '/flows/new');
return (
<Navigate
replace
to={returnUrl}
/>
);
}
return children;
}
export default PublicRoute;
+153
View File
@@ -0,0 +1,153 @@
# Shared list/detail building blocks
This directory hosts the reusable surface for list-and-detail pages: a
filterable table, a Prev/Next/Sheet toolbar that walks the _same_ filtered
subset on detail pages, and the inline-rename + sortable-header primitives
that every list reuses.
## Mental model
```
┌──────────────────────────────────────────┐
│ URL ?q=foo ?page=3 │
│ (source of truth — bookmarkable) │
└────────┬─────────────────────┬───────────┘
│ read/write │ read-only
▼ ▼
useTableQueryFilter useTableQueryFilterReader
usePagination │
│ │
▼ ▼
<DataTable> useNavigation
(list page) │
│ ▼
▼ <DetailNavigationToolbar>
table_4_<path> (detail page)
in localStorage
(cold-start fallback)
```
- **URL is authoritative.** Filter (`?q=`) and page (`?page=`) live in the
URL so links/bookmarks always reproduce the user's view.
- **Storage is a warm-restart bag.** The list page persists the URL filter
into `localStorage` under `table_4_<path>`. The detail page never writes
storage and never replays storage into the URL — opening a shared link
shows exactly what the link says.
- **Prev/Next walks the same subset.** `DetailNavigationToolbar` runs the
same matcher (`createTextMatcher`) the list filter uses, so siblings stay
in lockstep with what the user sees in the table.
## Components
| File | Role |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| [`detail-navigation/`](detail-navigation/) | Prev / Position / Next toolbar + listbox sheet for detail pages, and the navigation hooks that feed it. |
| [`inline-edit/`](inline-edit/) | Generic inline-edit input (Save/Cancel addons, Enter/Escape) plus the paired `useInlineEdit` state machine. |
## Hooks
| Hook | Where | Source of truth | Writes? | Notes |
| --------------------------- | ------------------------------- | --------------- | ------- | ------------------------------------------------------------------ |
| `useTableQueryFilter` | `@/hooks/` | URL `?q=` | yes | List pages. Restores from `localStorage` on cold start. |
| `useTableQueryFilterReader` | `@/hooks/` | URL `?q=` | no | Detail pages. Storage-blind — shared links never gain stale `?q=`. |
| `usePagination` | `@/hooks/` | URL `?page=` | yes | Canonicalizes `?page=1` away so the URL has one form per view. |
| `useNavigation` | `detail-navigation/` (internal) | props | no | Pure computation of Prev/Next around a `currentId`. |
| `useDetailNavigation` | `detail-navigation/` | URL + props | no | Bundles the three above into a single hook for detail pages. |
| `useInlineEdit` | `inline-edit/` | local state | no | Edit-mode toggle + deferred focus (Radix dropdown race fix). |
| `usePageStorageKeys` | `@/hooks/` | router | no | Resolves the three per-page storage keys reactively. |
## Library helpers (in `@/lib/`)
| Module | Purpose |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `table-state.ts` | Unified `table_4_<path>` JSON slot. Carries filter + sorting + columnVis + pageSize. |
| `view-options-storage.ts` | `viewOptions_4_<path>` for FileManager-style screens (folders-first, etc.). |
| `storage-keys.ts` | Single source of truth for storage-key conventions and `getTopLevelPath`. |
| `url-params.ts` | `URL_PARAMS` constants + `mergeHrefWithSearchParams` (preserves hash on merge). |
## How to add a new list + detail pair
1. **List page** (`/<entities>/`):
```tsx
const { filter, setFilter } = useTableQueryFilter();
const { pageIndex, setPage } = usePagination();
return (
<DataTable
columns={columns /* use <DataTableColumnHeader column={column} title="..." /> */}
data={entities}
filterColumn="title"
filterValue={filter}
onFilterChange={setFilter}
onPageChange={setPage}
pageIndex={pageIndex}
/>
);
```
2. **Feature-scoped navigation hook** (`@/features/<entity>/use-<entity>-detail-navigation.ts`):
```ts
const getLabel = (item: Entity) => item.title;
const getHref = (item: Entity) => `/<entities>/${item.id}`;
export const useEntityDetailNavigation = (currentId: null | string | undefined) => {
const { entities } = useEntities();
return useDetailNavigation<Entity>({ currentId, getHref, getLabel, items: entities });
};
```
3. **Detail page** (`/<entities>/:id`):
```tsx
const { toolbarProps } = useEntityDetailNavigation(entityId);
return (
<header>
<DetailNavigationToolbar<Entity>
{...toolbarProps}
sheetIcon={<Icon className="size-4" />}
sheetTitle="Entities"
renderItem={(item, isCurrent) => <span>{item.title}</span>}
/>
</header>
);
```
## Why URL > storage
A user opens `/flows?q=alpha` in tab A. They navigate to flow B by clicking
"Next" in the toolbar. They share `/flows/b?q=alpha` with a teammate.
- The teammate opens the link cold. Their detail page reads `q=alpha` from
the URL and renders Prev/Next over the filtered subset.
- The teammate hits "Next". They land on `/flows/c?q=alpha` — still inside
the filter, even though they never typed it.
`useTableQueryFilterReader` is the key piece: it observes the URL but never
writes anything, so a fresh detail-page mount can't accidentally inject the
**previous tab's** `?q=` into the URL.
## Why one storage key per page
Before the unification, every list page wrote four storage keys
(`column_4_/flows`, `sorting_4_/flows`, `filter_4_/flows`, `page_4_/flows`)
in two different write paths (sync + debounced). Refreshing during a typing
session could land you in an inconsistent state. The unified
`table_4_<path>` slot is a single JSON object that all preferences live in;
`migrateLegacyTableState` folds the four legacy keys into it on first mount
and deletes them.
## Testing notes
- `vitest run` covers the pure utilities (`table-state`,
`view-options-storage`, `url-params`), the hook behaviours
(`use-pagination`, `use-table-query-filter`, `use-inline-edit`,
`use-page-storage-keys`, `use-detail-navigation`), and the components
(`detail-navigation/`, `data-table`).
- jsdom doesn't ship `Element.prototype.scrollIntoView` or `ResizeObserver`
— both are polyfilled in `vitest.setup.ts`.
- React Testing Library auto-cleans the DOM after every test (see the same
setup file). Tests can freely call `render` without leaking nodes.
@@ -0,0 +1,140 @@
import type { ReactElement } from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { cloneElement, isValidElement, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
type ConfirmationDialogIconProps = ReactElement<React.SVGProps<SVGSVGElement>>;
interface ConfirmationDialogProps {
cancelIcon?: ConfirmationDialogIconProps;
cancelText?: string;
cancelVariant?: 'default' | 'destructive' | 'ghost' | 'outline' | 'secondary';
confirmIcon?: ConfirmationDialogIconProps;
confirmText?: string;
confirmVariant?: 'default' | 'destructive' | 'ghost' | 'outline' | 'secondary';
description?: string;
/** May be sync or async. If async, the dialog keeps itself open and shows a spinner until the promise settles. */
handleConfirm: () => Promise<void> | void;
handleOpenChange: (isOpen: boolean) => void;
isOpen: boolean;
itemName?: string;
itemType?: string;
title?: string;
}
function ConfirmationDialog({
cancelIcon,
cancelText = 'Cancel',
cancelVariant = 'outline',
confirmIcon = <Trash2 />,
confirmText = 'Confirm',
confirmVariant = 'destructive',
description,
handleConfirm,
handleOpenChange,
isOpen,
itemName = 'this',
itemType = 'item',
title,
}: ConfirmationDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
// Derive a contextual title from confirm verb + item type so callers don't
// see "Confirm Action" for a Delete prompt or a Save prompt. Explicit
// `title` always wins.
const verb = confirmText.trim();
const resolvedTitle = title ?? (verb && verb !== 'Confirm' ? `${verb} ${itemType}` : 'Confirm Action');
const defaultDescription = description || (
<>
Are you sure you want to {verb.toLowerCase() || 'perform this action on'}{' '}
<strong className="text-foreground font-semibold">{itemName}</strong> {itemType}?
</>
);
const processIcon = (icon?: ConfirmationDialogIconProps): ConfirmationDialogIconProps | null => {
if (!icon) {
return null;
}
if (isValidElement(icon)) {
const { className = '', ...restProps } = icon.props;
return cloneElement(icon, {
...restProps,
className: cn('size-4', className),
});
}
return icon;
};
const handleConfirmClick = async () => {
if (isProcessing) {
return;
}
setIsProcessing(true);
try {
await handleConfirm();
handleOpenChange(false);
} finally {
setIsProcessing(false);
}
};
return (
<Dialog
onOpenChange={(nextOpen) => {
if (isProcessing) {
return;
}
handleOpenChange(nextOpen);
}}
open={isOpen}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{resolvedTitle}</DialogTitle>
<DialogDescription>{defaultDescription}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
disabled={isProcessing}
onClick={() => handleOpenChange(false)}
variant={cancelVariant}
>
{processIcon(cancelIcon)}
{cancelText}
</Button>
<Button
disabled={isProcessing}
onClick={() => {
void handleConfirmClick();
}}
variant={confirmVariant}
>
{isProcessing ? <Loader2 className="size-4 animate-spin" /> : processIcon(confirmIcon)}
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ConfirmationDialog;
@@ -0,0 +1,94 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { DetailNavigationController } from './use-detail-navigation';
interface DetailNavigationButtonsProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
/** Lowercased plural used in the aria-label / tooltip ("flows", "templates"). */
sheetTitle: string;
/**
* Size variant. `'default'` is the desktop toolbar's `size-8` cluster;
* `'sm'` shrinks the cluster to `size-7` for embedding inside a
* `<DropdownMenuItem>` on mobile, where the host row is already padded.
*/
size?: 'default' | 'sm';
}
/**
* Prev / Position / Next button cluster bound to a `DetailNavigationController`.
* Stateless: the controller owns navigation, `isSheetOpen`, and the
* pre-formatted `positionLabel`.
*
* Reused in both the desktop toolbar (`size="default"`) and the mobile
* dropdown row (`size="sm"`) — same a11y contract, same tooltips, same
* keyboard semantics in both places.
*/
export function DetailNavigationButtons<T extends { id: string }>({
controller,
sheetTitle,
size = 'default',
}: DetailNavigationButtonsProps<T>) {
const lowerTitle = sheetTitle.toLowerCase();
const isSm = size === 'sm';
const sideButtonSize = isSm ? 'size-7' : 'size-8';
const middleHeight = isSm ? 'h-7' : 'h-8';
return (
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Previous"
className={cn(sideButtonSize, 'rounded-r-none border-r-0 p-0')}
disabled={!controller.prevId}
onClick={controller.goToPrev}
size="icon"
type="button"
variant="outline"
>
<ChevronLeft />
</Button>
</TooltipTrigger>
<TooltipContent>Previous</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={`Open ${lowerTitle} list (${controller.positionLabel})`}
className={cn(
middleHeight,
'min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums',
)}
disabled={!controller.hasEntries}
onClick={controller.openSheet}
type="button"
variant="outline"
>
{controller.positionLabel}
</Button>
</TooltipTrigger>
<TooltipContent>Show all matching {lowerTitle}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Next"
className={cn(sideButtonSize, 'rounded-l-none border-l-0 p-0')}
disabled={!controller.nextId}
onClick={controller.goToNext}
size="icon"
type="button"
variant="outline"
>
<ChevronRight />
</Button>
</TooltipTrigger>
<TooltipContent>Next</TooltipContent>
</Tooltip>
</div>
);
}
@@ -0,0 +1,428 @@
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DetailNavigationSheet } from './detail-navigation-sheet';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
title: string;
}
const ITEMS: readonly Item[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
{ id: 'd', title: 'Delta' },
] as const;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const getSearchableText = (item: Item) => item.title;
const LocationReadout = () => {
const { pathname, search } = useLocation();
return (
<span data-testid="location">
{pathname}
{search}
</span>
);
};
interface HarnessProps {
currentId?: null | string;
defaultSearchQuery?: string;
filter?: string;
hasSearch?: boolean;
items?: readonly Item[];
searchPlaceholder?: string;
}
/**
* Render the sheet open by default (`defaultOpen: true`) so keyboard /
* focus / a11y interactions can be exercised without round-tripping through
* the toolbar's position button. Keeps each test focused on the leaf.
*/
const SheetHarness = ({
currentId = 'c',
defaultSearchQuery,
hasSearch,
items = ITEMS,
searchPlaceholder,
}: HarnessProps) => {
const nav = useDetailNavigation<Item>({
currentId,
defaultOpen: true,
defaultSearchQuery,
getHref,
getLabel,
getSearchableText,
items,
// Skip the debounce so each test sees the post-typing subset on the
// next paint instead of having to wait `>150ms` per keystroke.
searchDebounceMs: 0,
});
return (
<DetailNavigationSheet<Item>
controller={nav}
hasSearch={hasSearch}
searchPlaceholder={searchPlaceholder}
sheetTitle="Items"
/>
);
};
const renderSheet = (props: HarnessProps = {}) => {
const filter = props.filter ?? '';
const initialId = props.currentId ?? 'c';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[`/items/${initialId}?q=${filter}`]}>
<TooltipProvider>
<LocationReadout />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</TooltipProvider>
</MemoryRouter>
);
return render(<SheetHarness {...props} />, { wrapper: Wrapper });
};
describe('DetailNavigationSheet — a11y / aria contract', () => {
it('renders the listbox with the sheet title as accessible name (aria-describedby opt-out preserved)', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox', { name: 'Items' });
expect(listbox).toBeInTheDocument();
});
it('marks the current item with aria-selected', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
const current = within(listbox).getByRole('option', { selected: true });
expect(current).toHaveAttribute('data-item-id', 'c');
});
});
describe('DetailNavigationSheet — roving tabIndex', () => {
it('only the current option carries tabIndex={0}', async () => {
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
const options = within(listbox).getAllByRole('option');
await waitFor(() => {
const focusable = options.filter((option) => option.getAttribute('tabindex') === '0');
expect(focusable).toHaveLength(1);
expect(focusable[0]).toHaveAttribute('data-item-id', 'c');
});
const nonFocusable = options.filter((option) => option.getAttribute('tabindex') === '-1');
expect(nonFocusable.length).toBe(options.length - 1);
});
it('falls back to the first filtered option when current is outside the subset', async () => {
renderSheet({ currentId: 'zzz' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
});
describe('DetailNavigationSheet — keyboard navigation', () => {
it('ArrowDown moves roving focus to the next option', async () => {
renderSheet({ currentId: 'b' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'b');
});
fireEvent.keyDown(listbox, { key: 'ArrowDown' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'c');
});
});
it('ArrowUp at the first option clamps (no wrap)', async () => {
renderSheet({ currentId: 'a' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
fireEvent.keyDown(listbox, { key: 'ArrowUp' });
// Focus stays on the first option — no wrap-around.
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
it('End jumps roving focus to the last option', async () => {
renderSheet({ currentId: 'a' });
const listbox = await screen.findByRole('listbox');
fireEvent.keyDown(listbox, { key: 'End' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
});
it('Home jumps roving focus to the first option', async () => {
renderSheet({ currentId: 'd' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'd');
});
fireEvent.keyDown(listbox, { key: 'Home' });
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
});
});
describe('DetailNavigationSheet — selection', () => {
it('clicking an option navigates and closes the sheet', async () => {
const user = userEvent.setup();
renderSheet({ currentId: 'c' });
const listbox = await screen.findByRole('listbox');
await user.click(within(listbox).getByRole('option', { name: 'Alpha' }));
await waitFor(() => {
expect(screen.queryByRole('listbox', { name: 'Items' })).not.toBeInTheDocument();
});
expect(screen.getByTestId('location').textContent).toContain('/items/a');
});
it('narrows the listbox to filtered items', async () => {
renderSheet({ currentId: 'a', filter: 'pha' });
const listbox = await screen.findByRole('listbox', { name: 'Items' });
await waitFor(() => {
const labels = within(listbox)
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Alpha']);
});
});
});
describe('DetailNavigationSheet — search input', () => {
it('renders the search input by default (hasSearch=true)', async () => {
renderSheet({ currentId: 'c' });
const input = await screen.findByRole('textbox');
expect(input).toBeInTheDocument();
expect(input).toHaveValue('');
});
it('hides the search input when hasSearch is false (opt-out)', async () => {
renderSheet({ currentId: 'c', hasSearch: false });
await screen.findByRole('listbox');
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
it('uses the supplied placeholder', async () => {
renderSheet({ currentId: 'c', searchPlaceholder: 'Find item' });
const input = await screen.findByPlaceholderText('Find item');
expect(input).toBeInTheDocument();
});
it('typing narrows the listbox immediately when searchDebounceMs=0', async () => {
renderSheet({ currentId: 'a' });
const input = await screen.findByRole('textbox');
// `fireEvent.change` — `user.type` races with the Radix focus trap (see
// the URL-filter AND test below for the same sidestep).
fireEvent.change(input, { target: { value: 'cha' } });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const labels = within(listbox)
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Charlie']);
});
});
it('renders a clear button while the query is non-empty; clicking it resets', async () => {
const user = userEvent.setup();
renderSheet({ currentId: 'a', defaultSearchQuery: 'cha' });
const clearButton = await screen.findByRole('button', { name: 'Clear search' });
await user.click(clearButton);
const input = await screen.findByRole('textbox');
expect(input).toHaveValue('');
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const labels = within(listbox)
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta']);
});
});
it('Escape clears a non-empty query without closing the sheet', async () => {
const user = userEvent.setup();
renderSheet({ currentId: 'a', defaultSearchQuery: 'cha' });
const input = await screen.findByRole('textbox');
input.focus();
await user.keyboard('{Escape}');
expect(input).toHaveValue('');
// Sheet stayed mounted because the keydown was prevented + stopped.
expect(screen.queryByRole('listbox', { name: 'Items' })).toBeInTheDocument();
});
it('shows a query-specific empty state when nothing matches', async () => {
renderSheet({ currentId: 'a', defaultSearchQuery: 'zzzzz' });
await waitFor(() => {
expect(screen.getByText(/No items match "zzzzz"\./)).toBeInTheDocument();
});
expect(screen.queryByRole('listbox', { name: 'Items' })).not.toBeInTheDocument();
});
it('ANDs the in-sheet search with the URL ?q= filter', async () => {
// URL filter narrows to Alpha + Bravo + Charlie (substring "a"); the
// local "bra" then narrows further to just Bravo. Use a single
// `fireEvent.change` instead of `user.type` so the assertion does
// not race with React's per-keystroke re-renders.
renderSheet({ currentId: 'b', filter: 'a' });
const input = await screen.findByRole('textbox');
fireEvent.change(input, { target: { value: 'bra' } });
const listbox = await screen.findByRole('listbox', { name: 'Items' });
await waitFor(() => {
const labels = within(listbox)
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Bravo']);
});
});
it('ArrowDown from the input moves roving focus into the listbox', async () => {
// Start at currentId 'b' so the initial roving focus is 'b'; that way
// we can prove ArrowDown from the *input* targeted the first item ('a')
// rather than just no-op'ing on already-focused 'a'.
renderSheet({ currentId: 'b' });
const input = await screen.findByRole('textbox');
fireEvent.keyDown(input, { key: 'ArrowDown' });
const listbox = await screen.findByRole('listbox');
await waitFor(() => {
const focused = within(listbox)
.getAllByRole('option')
.find((option) => option.getAttribute('tabindex') === '0');
expect(focused).toHaveAttribute('data-item-id', 'a');
});
// ArrowDown jumps DOM focus to the new option synchronously — the
// auto-focus effect skips when focus is on the search input, so this
// handler has to move focus itself. Without it, the user would have
// to click the option to interact with it after typing.
expect(document.activeElement).toHaveAttribute('data-item-id', 'a');
});
it('does not yank focus onto a listbox option when filteredItems shrinks (regression)', async () => {
// Reproduces the bug from the original implementation: typing a
// query that drops the current item from the subset triggered the
// auto-focus effect to grab focus onto the new `focusedId` (the
// first survivor) at the debounce boundary, interrupting typing.
//
// `currentId='d'` + query `'rav'` → Delta is gone, subset becomes
// [Bravo], render-phase reconciliation sets `focusedId='b'`. With
// the fix the effect observes the search input is focused and
// refuses to steal focus onto the option.
//
// (The exact post-shrink `activeElement` is sensitive to Radix's
// JSDOM-only focus-trap fallback behaviour — in a real browser
// focus stays on the input, but JSDOM may bounce it onto the
// dialog container if the previously-focused option unmounts. The
// bug is specifically about the listbox option stealing focus, so
// we assert against *that*, not against equality with the input.)
renderSheet({ currentId: 'd' });
const input = await screen.findByRole('textbox');
// Radix Dialog runs its own open-time focus trap. Wait for it to
// settle, then put focus on the input as the user would after
// clicking it.
await waitFor(() => {
input.focus();
expect(document.activeElement).toBe(input);
});
fireEvent.change(input, { target: { value: 'rav' } });
await waitFor(() => {
const labels = within(screen.getByRole('listbox'))
.getAllByRole('option')
.map((option) => option.textContent ?? '');
expect(labels).toEqual(['Bravo']);
});
// The exact failure mode of the original bug: focus jumps onto a
// `role="option"` button when the list shrinks. With the fix it
// never lands there until the user explicitly arrows into the list.
expect(document.activeElement?.getAttribute('role')).not.toBe('option');
});
});
@@ -0,0 +1,443 @@
import { Search, X } from 'lucide-react';
import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import type { DetailNavigationController } from './use-detail-navigation';
interface DetailNavigationSheetProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
/**
* Render a free-text search input in the sheet header that drives
* `controller.setSearchQuery`. Defaults to `true` — pass `false` to opt
* out for the rare consumer that wants the sheet to stay URL-filter-only.
*/
hasSearch?: boolean;
renderItem?: (item: T, isCurrent: boolean) => ReactNode;
/** Placeholder for the in-sheet search input. Defaults to "Search…". */
searchPlaceholder?: string;
sheetIcon?: ReactNode;
sheetTitle: string;
}
/**
* Listbox-style overlay listing the navigable subset.
*
* Implements the WAI-ARIA single-select listbox pattern with **roving
* tabindex**: only the currently-focused option carries `tabIndex={0}`,
* the rest are `tabIndex={-1}`. Tab takes the user *past* the listbox in
* one step; arrow keys move focus *within* it.
*
* Initial focus on open targets the current entry (if it's part of the
* filtered subset) so users land oriented inside their own context.
*/
export function DetailNavigationSheet<T extends { id: string }>({
controller,
hasSearch = true,
renderItem,
searchPlaceholder = 'Search…',
sheetIcon,
sheetTitle,
}: DetailNavigationSheetProps<T>) {
// Destructure at the top so existing `useMemo` / `useEffect` deps below
// read individual fields rather than the controller object — keeps the
// identity story the same as before the refactor.
const {
clearSearchQuery,
currentId,
currentIndex,
filteredItems: items,
getId,
getLabel,
handleItemSelect: onItemSelect,
isSheetOpen: open,
searchQuery,
setSearchQuery,
setSheetOpen: onOpenChange,
total,
} = controller;
const listRef = useRef<HTMLUListElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
const [focusedId, setFocusedId] = useState<null | string>(null);
const hasEntries = items.length > 0;
const trimmedQuery = searchQuery.trim();
const hasClearButton = hasSearch && trimmedQuery.length > 0;
// Build an `id → index` map once per `items`/`getId` change so both the
// per-render membership check below and the keyboard handler's lookup
// stay O(1) instead of O(n). The IIFE that adjusts focus during render
// previously called `items.some(...)` on every commit; the keyboard
// handler ran `items.findIndex(...)` on every keystroke. Sharing one
// structure between the two also makes the contract explicit: an entry
// is "in the filtered subset" iff `indexById.has(id)`.
const indexById = useMemo(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
map.set(String(getId(item)), index);
});
return map;
}, [items, getId]);
// Single render-phase focus reconciliation. React's "adjust state when a
// prop changes" idiom — see https://react.dev/reference/react/useState#storing-information-from-previous-renders
// — collapsed into one comparison so the next desired focus is decided
// once per render and committed in the same pass that prompted it (no
// flash of stale focus, no double-setState ping-pong on edge cases like
// "items change while the sheet was reopening with no current item").
//
// Priorities, top-down:
// 1. open→close / close→open transition: re-pin to `currentId` (or the
// first entry when no current exists) on open, and clear on close.
// 2. While the sheet stays open, if the focused entry left the
// filtered subset (list page narrowed the filter behind it), fall
// back to the first survivor — otherwise the keyboard model would
// stall on a row that's no longer rendered.
// 3. Otherwise hold whatever focus the user chose via arrow keys.
//
// `lastOpen` starts at `false` so an initial `open=true` still trips
// the open transition on the very first render.
const [lastOpen, setLastOpen] = useState(false);
const desiredFocusId = (() => {
if (lastOpen !== open) {
if (!open) {
return null;
}
const firstItem = items[0];
if (!firstItem) {
return null;
}
// `currentId != null` narrows to `string`; the controller has
// already verified `currentId` belongs to the filtered subset
// when it computed `currentIndex`, so no re-scan needed.
return currentId != null && currentIndex >= 0 ? String(currentId) : String(getId(firstItem));
}
if (open && focusedId !== null && hasEntries && !indexById.has(focusedId)) {
const fallbackItem = items[0];
return fallbackItem ? String(getId(fallbackItem)) : null;
}
return focusedId;
})();
if (lastOpen !== open) {
setLastOpen(open);
}
if (desiredFocusId !== focusedId) {
setFocusedId(desiredFocusId);
}
// After roving focus moves, push the focus into the DOM. `rAF` defers past
// Radix's own focus management so we don't fight its open-time focus trap.
//
// Gate: only auto-focus an option when the user's focus is already
// somewhere that *expects* roving (a list option) or has not yet landed
// (`document.activeElement === document.body` right after open, before
// Radix moves focus into the dialog). Crucially, we must NOT steal focus
// when the user is typing in the search input: every keystroke that
// flushes the debounce can re-target `focusedId` (when `currentId` falls
// out of the filtered subset, render-phase reconciliation snaps focus to
// the first survivor). Without this gate, focus jumps out of the input
// mid-type and the user can only enter a few characters before losing
// their place — exact repro: type "aes" with current flow 837 visible,
// focus moves to button 836 at the 150 ms debounce boundary.
//
// The fix is at the cause, not the symptom: a local input mirror (the
// `InputSearch` / `DataTableFilter` pattern) would keep keystrokes from
// being dropped during a state round-trip, but it would not stop this
// effect from yanking focus away. The two patterns solve different bugs.
useEffect(() => {
if (!open || focusedId === null) {
return;
}
const activeEl = document.activeElement;
const focusIsOnSearchInput = activeEl !== null && activeEl === searchInputRef.current;
if (focusIsOnSearchInput) {
return;
}
const id = requestAnimationFrame(() => {
const node = buttonRefs.current.get(focusedId);
if (!node) {
return;
}
node.focus();
if (focusedId === String(currentId ?? '')) {
node.scrollIntoView({ block: 'center' });
}
});
return () => cancelAnimationFrame(id);
}, [open, focusedId, currentId]);
// Translate arrow / Home / End into roving moves over `items`. Using the
// array index instead of `querySelectorAll` keeps the keyboard model in
// step with the React tree even if the sheet ever virtualises the list.
// O(1) lookup via the shared `indexById` map — `findIndex` would scan on
// every keystroke for nothing.
const handleListKeyDown = useCallback(
(event: KeyboardEvent<HTMLUListElement>) => {
if (!hasEntries || focusedId === null) {
return;
}
const focusedIndex = indexById.get(focusedId);
if (focusedIndex === undefined) {
return;
}
const moveTo = (index: number) => {
event.preventDefault();
const target = items[index];
if (target) {
setFocusedId(String(getId(target)));
}
};
if (event.key === 'ArrowDown') {
moveTo(Math.min(focusedIndex + 1, items.length - 1));
return;
}
if (event.key === 'ArrowUp') {
moveTo(Math.max(focusedIndex - 1, 0));
return;
}
if (event.key === 'Home') {
moveTo(0);
return;
}
if (event.key === 'End') {
moveTo(items.length - 1);
}
},
[focusedId, getId, hasEntries, indexById, items],
);
const handleItemClick = useCallback(
(item: T) => {
onItemSelect(item);
},
[onItemSelect],
);
// ArrowDown from the input jumps focus into the listbox so keyboard
// users can flow `type → arrow down → enter` without hunting for the
// list. We move focus *here* synchronously (not in the auto-focus
// effect) because that effect now skips when focus is on the search
// input — otherwise it would yank focus mid-type. Escape is *not*
// handled here — Radix listens for it on a native document handler
// that React-level `stopPropagation` cannot reach. See
// `handleEscapeKeyDown` below, wired through `onEscapeKeyDown`.
const handleSearchKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown' && hasEntries) {
event.preventDefault();
const first = items[0];
if (!first) {
return;
}
const firstId = String(getId(first));
setFocusedId(firstId);
// Button refs for the current items are already mounted —
// this handler fires during a real user keystroke, so the
// listbox commit that wired them up has already happened.
buttonRefs.current.get(firstId)?.focus();
}
},
[getId, hasEntries, items],
);
// Intercept Esc at the Radix-Content level so we can clear a non-empty
// search before the dialog's built-in "close on Esc" fires. Once the
// query is empty, we let Radix close as usual — matches the two-step
// Esc affordance of `InputSearch` (clear, then dismiss).
const handleEscapeKeyDown = useCallback(
(event: KeyboardEvent) => {
if (hasSearch && trimmedQuery.length > 0) {
event.preventDefault();
clearSearchQuery();
}
},
[clearSearchQuery, hasSearch, trimmedQuery.length],
);
const handleSearchChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(event.target.value);
},
[setSearchQuery],
);
const handleSearchClear = useCallback(() => {
clearSearchQuery();
searchInputRef.current?.focus();
}, [clearSearchQuery]);
// One stable callback ref reused for every button. The previous shape —
// `setButtonRef(id) => (node) => …` — manufactured a new closure per id
// on every render, which made React re-attach refs (a `delete` + `set`
// round-trip on the `Map`) on every list re-render. Reading the id from
// `data-item-id` keeps the closure identity-stable and the React-19
// cleanup return value handles unmount without leaks.
const setButtonRef = useCallback((node: HTMLButtonElement | null) => {
if (!node) {
return;
}
const id = node.dataset.itemId;
if (!id) {
return;
}
buttonRefs.current.set(id, node);
return () => {
buttonRefs.current.delete(id);
};
}, []);
return (
<Sheet
onOpenChange={onOpenChange}
open={open}
>
<SheetContent
// Radix expects either a `<Description>` or an explicit
// `aria-describedby={undefined}` opt-out. The sheet is just a
// listbox of items, the `SheetTitle` already describes it.
aria-describedby={undefined}
className="flex w-full max-w-sm flex-col gap-0 p-0 sm:max-w-sm"
onEscapeKeyDown={handleEscapeKeyDown}
side="right"
>
<SheetHeader className="gap-3 border-b p-4">
<SheetTitle className="flex items-center gap-2 pr-8 text-base">
{sheetIcon}
<span>{sheetTitle}</span>
<Badge
className="ml-auto font-normal tabular-nums"
variant="secondary"
>
{total}
</Badge>
</SheetTitle>
{hasSearch ? (
<InputGroup className="h-9">
<InputGroupAddon align="inline-start">
<Search
aria-hidden="true"
className="text-muted-foreground"
/>
</InputGroupAddon>
<InputGroupInput
aria-label={searchPlaceholder}
className="h-9 py-0"
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder}
ref={searchInputRef}
type="text"
value={searchQuery}
/>
{hasClearButton ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label="Clear search"
onClick={handleSearchClear}
size="icon-sm"
type="button"
variant="ghost"
>
<X aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
) : null}
</SheetHeader>
{hasEntries ? (
<div className="min-w-0 flex-1 overflow-y-auto">
<ul
aria-label={sheetTitle}
className="flex flex-col gap-0.5 p-2"
onKeyDown={handleListKeyDown}
ref={listRef}
role="listbox"
>
{items.map((item) => {
const id = String(getId(item));
const isCurrent = currentId != null && id === String(currentId);
const isFocused = id === focusedId;
return (
<li
className="min-w-0"
key={id}
role="presentation"
>
<button
aria-selected={isCurrent}
className={cn(
'hover:bg-muted/50 focus-visible:ring-ring flex w-full min-w-0 items-center gap-2 rounded-md px-3 py-2 text-left text-sm focus-visible:ring-2 focus-visible:outline-hidden',
isCurrent && 'bg-muted text-foreground font-medium',
)}
data-item-id={id}
onClick={() => handleItemClick(item)}
onFocus={() => setFocusedId(id)}
ref={setButtonRef}
role="option"
tabIndex={isFocused ? 0 : -1}
type="button"
>
{renderItem ? (
renderItem(item, isCurrent)
) : (
<span className="min-w-0 flex-1 truncate">{getLabel(item)}</span>
)}
</button>
</li>
);
})}
</ul>
</div>
) : (
<div className="text-muted-foreground flex flex-1 items-center justify-center px-4 text-center text-sm">
{trimmedQuery.length > 0
? `No items match "${trimmedQuery}".`
: 'No items match the current filter.'}
</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,138 @@
import type { ReactNode } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { DetailNavigationToolbar } from './detail-navigation-toolbar';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
title: string;
}
const ITEMS: readonly Item[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
{ id: 'd', title: 'Delta' },
] as const;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const LocationReadout = () => {
const { pathname, search } = useLocation();
return (
<span data-testid="location">
{pathname}
{search}
</span>
);
};
interface HarnessProps {
currentId?: null | string;
filter?: string;
items?: readonly Item[];
}
const ToolbarHarness = ({ currentId = 'c', items = ITEMS }: HarnessProps) => {
const nav = useDetailNavigation<Item>({
currentId,
getHref,
getLabel,
items,
});
return (
<DetailNavigationToolbar<Item>
controller={nav}
sheetTitle="Items"
/>
);
};
const renderToolbar = (props: HarnessProps = {}) => {
const filter = props.filter ?? '';
const initialId = props.currentId ?? 'c';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[`/items/${initialId}?q=${filter}`]}>
<TooltipProvider>
<LocationReadout />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</TooltipProvider>
</MemoryRouter>
);
return render(<ToolbarHarness {...props} />, { wrapper: Wrapper });
};
describe('DetailNavigationToolbar', () => {
it('renders nothing when raw items is empty', () => {
renderToolbar({ items: [] });
expect(screen.queryByRole('button', { name: /Previous/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Next/i })).not.toBeInTheDocument();
});
it('composes Buttons + Sheet: position button opens the listbox', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'c' });
// Buttons present (smoke).
expect(screen.getByRole('button', { name: /Previous/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument();
// Position trigger opens the sheet.
await user.click(screen.getByRole('button', { name: /3\/4/ }));
const listbox = await screen.findByRole('listbox', { name: 'Items' });
expect(listbox).toBeInTheDocument();
});
it('shows `/total` when current is missing from the filtered subset', () => {
renderToolbar({ currentId: 'zzz' });
expect(screen.getByRole('button', { name: /\/4/ })).toBeInTheDocument();
});
it('disables Prev for the first item', () => {
renderToolbar({ currentId: 'a' });
expect(screen.getByRole('button', { name: /Previous/i })).toBeDisabled();
});
it('disables Next for the last item', () => {
renderToolbar({ currentId: 'd' });
expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled();
});
it('Next navigates to the next sibling preserving `?q=`', async () => {
const user = userEvent.setup();
renderToolbar({ currentId: 'a', filter: 'a' });
await user.click(screen.getByRole('button', { name: /Next/i }));
await waitFor(() => {
expect(screen.getByTestId('location').textContent).toContain('/items/b');
});
expect(screen.getByTestId('location').textContent).toContain('q=a');
});
it('disables the position button when the filter excludes every item', async () => {
renderToolbar({ currentId: 'c', filter: 'qqqqqqqqq' });
// After debounce settles, the empty subset disables the trigger.
await waitFor(() => {
expect(screen.getByRole('button', { name: /\/0/ })).toBeDisabled();
});
});
});
@@ -0,0 +1,60 @@
import type { ReactNode } from 'react';
import type { DetailNavigationController } from './use-detail-navigation';
import { DetailNavigationButtons } from './detail-navigation-buttons';
import { DetailNavigationSheet } from './detail-navigation-sheet';
export interface DetailNavigationToolbarProps<T extends { id: string }> {
controller: DetailNavigationController<T>;
/**
* Forwarded to `<DetailNavigationSheet>` — controls the in-sheet search
* input. Defaults to `true`; pass `false` to opt out.
*/
hasSearch?: boolean;
renderItem?: (item: T, isCurrent: boolean) => ReactNode;
/** Forwarded placeholder for the in-sheet search input. */
searchPlaceholder?: string;
sheetIcon?: ReactNode;
sheetTitle: string;
}
/**
* Convenience wrapper that composes `<DetailNavigationButtons>` and
* `<DetailNavigationSheet>` against a single `DetailNavigationController`.
* Most desktop call sites use this directly; pages with non-standard chrome
* (e.g. mobile prev/position/next inside a `<DropdownMenuItem>`) can compose
* the leaves themselves and read from the same controller.
*
* Renders `null` when the controller reports `itemsEmpty` — saves the user
* from a momentary "/0" flash while the parent provider's data is in flight.
*/
export function DetailNavigationToolbar<T extends { id: string }>({
controller,
hasSearch,
renderItem,
searchPlaceholder,
sheetIcon,
sheetTitle,
}: DetailNavigationToolbarProps<T>) {
if (controller.itemsEmpty) {
return null;
}
return (
<>
<DetailNavigationButtons
controller={controller}
sheetTitle={sheetTitle}
/>
<DetailNavigationSheet
controller={controller}
hasSearch={hasSearch}
renderItem={renderItem}
searchPlaceholder={searchPlaceholder}
sheetIcon={sheetIcon}
sheetTitle={sheetTitle}
/>
</>
);
}
@@ -0,0 +1,4 @@
export { DetailNavigationButtons } from './detail-navigation-buttons';
export { DetailNavigationSheet } from './detail-navigation-sheet';
export { DetailNavigationToolbar } from './detail-navigation-toolbar';
export { type DetailNavigationController, useDetailNavigation } from './use-detail-navigation';
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { createTextMatcher, matchesTextFilter, normalizeForFilter } from './text-filter';
describe('normalizeForFilter', () => {
it('lowercases ASCII text', () => {
expect(normalizeForFilter('FooBar')).toBe('foobar');
});
it('strips combining diacritics so accented characters fold to plain', () => {
expect(normalizeForFilter('café')).toBe('cafe');
expect(normalizeForFilter('résumé')).toBe('resume');
expect(normalizeForFilter('naïve')).toBe('naive');
});
it('is idempotent', () => {
const once = normalizeForFilter('Café');
expect(normalizeForFilter(once)).toBe(once);
});
});
describe('createTextMatcher', () => {
it('returns a matcher that accepts everything for an empty query', () => {
const matcher = createTextMatcher('');
expect(matcher('anything')).toBe(true);
expect(matcher('')).toBe(true);
expect(matcher(null)).toBe(true);
expect(matcher(undefined)).toBe(true);
});
it('matches case-insensitively', () => {
const matcher = createTextMatcher('Foo');
expect(matcher('foo')).toBe(true);
expect(matcher('FOO')).toBe(true);
expect(matcher('hello FOO bar')).toBe(true);
expect(matcher('bar')).toBe(false);
});
it('matches across diacritic-folded forms', () => {
const matcher = createTextMatcher('cafe');
expect(matcher('café')).toBe(true);
expect(matcher('Café au lait')).toBe(true);
expect(matcher('CAFÉ')).toBe(true);
});
it('folds diacritics in the query too', () => {
const matcher = createTextMatcher('Café');
expect(matcher('cafe')).toBe(true);
expect(matcher('CAFE')).toBe(true);
});
it('returns false for null and undefined text when query is non-empty', () => {
const matcher = createTextMatcher('foo');
expect(matcher(null)).toBe(false);
expect(matcher(undefined)).toBe(false);
});
it('does substring matching, not whole-word or prefix matching', () => {
const matcher = createTextMatcher('ell');
expect(matcher('hello')).toBe(true);
expect(matcher('shell shocked')).toBe(true);
expect(matcher('apricot')).toBe(false);
});
it('preserves whitespace exactly — does not trim the query', () => {
const matcher = createTextMatcher(' foo ');
expect(matcher(' foo ')).toBe(true);
expect(matcher('foo')).toBe(false);
});
it('returns the same matcher behaviour across many invocations (no internal state leak)', () => {
const matcher = createTextMatcher('abc');
for (let i = 0; i < 10; i += 1) {
expect(matcher('xxabcyy')).toBe(true);
expect(matcher('xyz')).toBe(false);
}
});
});
describe('matchesTextFilter', () => {
it('delegates to createTextMatcher and produces the same answers', () => {
expect(matchesTextFilter('hello', 'ell')).toBe(true);
expect(matchesTextFilter('hello', 'world')).toBe(false);
expect(matchesTextFilter(null, 'foo')).toBe(false);
expect(matchesTextFilter(null, '')).toBe(true);
});
it('folds diacritics symmetrically on both sides', () => {
expect(matchesTextFilter('résumé.pdf', 'resume')).toBe(true);
expect(matchesTextFilter('resume.pdf', 'résumé')).toBe(true);
});
});
@@ -0,0 +1,55 @@
/**
* Normalize a string for case- and diacritic-insensitive substring matching.
*
* `NFKD` decomposes accented characters into base + combining marks, then we
* strip the combining marks (`\p{Diacritic}` regex class) so e.g. `café`
* matches `cafe`. Lowercasing happens last to fold case differences.
*
* Exported for callers that need to align their own search semantics with the
* one this module uses (e.g. server-side prefiltering).
*/
export const normalizeForFilter = (text: string): string =>
text
.normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
/**
* Build a reusable text-matcher specialised for `query`. The query is
* normalized + lowercased once at factory time — when the resulting matcher
* is invoked N times during list filtering, that work is amortised to one
* allocation instead of N.
*
* The matcher uses substring matching semantics, with case + diacritic
* folding (`café` matches `cafe`). It is intentionally close to TanStack
* Table's default `'includesString'` so a single instance can drive both
* the list page's column filter and the detail page's Prev/Next subset
* without the two paths drifting out of sync.
*
* - Empty query → every row passes (matcher returns `true`).
* - `text === null | undefined` with a non-empty query → no match.
*/
export const createTextMatcher = (query: string): ((text: null | string | undefined) => boolean) => {
if (!query.length) {
return () => true;
}
const normalizedQuery = normalizeForFilter(query);
return (text) => {
if (text === null || text === undefined) {
return false;
}
return normalizeForFilter(text).includes(normalizedQuery);
};
};
/**
* One-shot variant of {@link createTextMatcher} for callers that need a
* single comparison and don't want to bother with the factory. Equivalent to
* `createTextMatcher(query)(text)` — kept as a thin wrapper for readability
* at call sites and to preserve the original API.
*/
export const matchesTextFilter = (text: null | string | undefined, query: string): boolean =>
createTextMatcher(query)(text);
@@ -0,0 +1,700 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDetailNavigation } from './use-detail-navigation';
interface Item {
id: string;
title: string;
}
const ITEMS: readonly Item[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
] as const;
const getHref = (item: Item) => `/items/${item.id}`;
const getLabel = (item: Item) => item.title;
const getSearchableText = (item: Item) => item.title;
const renderInRoute = (initialEntries: string[]) => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
return Wrapper;
};
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
describe('useDetailNavigation — default getId', () => {
it('uses item.id when no `getId` override is provided', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.getId(ITEMS[1])).toBe('b');
});
});
describe('useDetailNavigation — identity stability', () => {
it('keeps the controller reference stable across re-renders with unchanged inputs', () => {
const { rerender, result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
const first = result.current;
rerender();
// Downstream leaf components rely on this identity stability.
expect(result.current).toBe(first);
});
it('keeps `goToPrev` / `goToNext` / `handleItemSelect` identity-stable when nothing relevant changes', () => {
const { rerender, result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
const firstGoPrev = result.current.goToPrev;
const firstGoNext = result.current.goToNext;
const firstSelect = result.current.handleItemSelect;
const firstOpen = result.current.openSheet;
const firstSet = result.current.setSheetOpen;
rerender();
expect(result.current.goToPrev).toBe(firstGoPrev);
expect(result.current.goToNext).toBe(firstGoNext);
expect(result.current.handleItemSelect).toBe(firstSelect);
expect(result.current.openSheet).toBe(firstOpen);
expect(result.current.setSheetOpen).toBe(firstSet);
});
});
describe('useDetailNavigation — filter forwarding', () => {
it('exposes the URL `?q=` value through `controller.debouncedFilter` (debounced)', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b?q=alpha']) },
);
// `debouncedFilter` settles after the default 200ms debounce.
await waitFor(() => {
expect(result.current.debouncedFilter).toBe('alpha');
});
});
it('narrows `filteredItems` to the matching subset once the filter settles', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/a?q=pha']) },
);
await waitFor(() => {
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['a']);
});
expect(result.current.total).toBe(1);
expect(result.current.currentIndex).toBe(0);
});
});
describe('useDetailNavigation — derived state', () => {
it('reports prev/next neighbours for a middle item', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.currentIndex).toBe(1);
expect(result.current.prevId).toBe('a');
expect(result.current.nextId).toBe('c');
expect(result.current.total).toBe(3);
expect(result.current.hasEntries).toBe(true);
expect(result.current.itemsEmpty).toBe(false);
expect(result.current.positionLabel).toBe('2/3');
});
it('reports `-1` index and `/total` label when currentId is not in subset', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'zzz',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/zzz']) },
);
expect(result.current.currentIndex).toBe(-1);
expect(result.current.prevId).toBeNull();
expect(result.current.nextId).toBeNull();
expect(result.current.positionLabel).toBe('/3');
});
it('reports `itemsEmpty=true` and `/0` when input list is empty', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: [],
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.itemsEmpty).toBe(true);
expect(result.current.total).toBe(0);
expect(result.current.hasEntries).toBe(false);
expect(result.current.positionLabel).toBe('/0');
});
});
describe('useDetailNavigation — navigation actions', () => {
it('goToNext() navigates to the next sibling preserving `?q=`', async () => {
const LocationProbe = ({ onChange }: { onChange: (loc: string) => void }) => {
const { pathname, search } = useLocation();
onChange(`${pathname}${search}`);
return null;
};
const seen: string[] = [];
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a?q=a']}>
<LocationProbe onChange={(loc) => seen.push(loc)} />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
await waitFor(() => {
expect(result.current.debouncedFilter).toBe('a');
});
act(() => {
result.current.goToNext();
});
await waitFor(() => {
expect(seen.at(-1)).toContain('/items/b');
});
expect(seen.at(-1)).toContain('q=a');
});
it('goToPrev() is a no-op when prevId is null (no navigate)', async () => {
const LocationProbe = ({ onChange }: { onChange: (loc: string) => void }) => {
const { pathname } = useLocation();
onChange(pathname);
return null;
};
const seen: string[] = [];
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a']}>
<LocationProbe onChange={(loc) => seen.push(loc)} />
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
expect(result.current.prevId).toBeNull();
const before = seen.length;
act(() => {
result.current.goToPrev();
});
// No path change emitted.
expect(seen.length).toBe(before);
});
it('handleItemSelect closes the sheet *before* navigating', async () => {
const Wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={['/items/a']}>
<Routes>
<Route
element={<>{children}</>}
path="/items/:id"
/>
</Routes>
</MemoryRouter>
);
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: Wrapper },
);
expect(result.current.isSheetOpen).toBe(true);
act(() => {
result.current.handleItemSelect(ITEMS[2]);
});
// Sheet closes synchronously inside the same call — the navigate
// that follows can't race with a still-mounted sheet.
expect(result.current.isSheetOpen).toBe(false);
});
});
describe('useDetailNavigation — controlled sheet mode', () => {
it('respects `open={true}` even when `setSheetOpen(false)` is called', () => {
const onOpenChange = vi.fn();
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onOpenChange,
open: true,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(true);
act(() => {
result.current.setSheetOpen(false);
});
// Parent owns the state — controller doesn't flip without a prop change.
expect(result.current.isSheetOpen).toBe(true);
// But `onOpenChange` fires so the parent can observe the request.
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
it('toggles internal state and fires onOpenChange in uncontrolled mode', () => {
const onOpenChange = vi.fn();
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onOpenChange,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(false);
act(() => {
result.current.openSheet();
});
expect(result.current.isSheetOpen).toBe(true);
expect(onOpenChange).toHaveBeenLastCalledWith(true);
act(() => {
result.current.closeSheet();
});
expect(result.current.isSheetOpen).toBe(false);
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
it('honours defaultOpen=true on first render in uncontrolled mode', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
defaultOpen: true,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.isSheetOpen).toBe(true);
});
});
describe('useDetailNavigation — current item bookkeeping', () => {
it('treats a missing currentId as "no match" without throwing', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: undefined,
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.currentId).toBeNull();
expect(result.current.currentIndex).toBe(-1);
expect(result.current.filteredItems).toEqual(ITEMS);
});
});
describe('useDetailNavigation — local search (uncontrolled)', () => {
it('starts with an empty searchQuery and `isSearchActive=false` by default', () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.searchQuery).toBe('');
expect(result.current.debouncedSearchQuery).toBe('');
expect(result.current.isSearchActive).toBe(false);
// Without a query, the full subset is visible.
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['a', 'b', 'c']);
});
it('honours defaultSearchQuery on first render and surfaces it as active', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
defaultSearchQuery: 'bravo',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
searchDebounceMs: 0,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
expect(result.current.searchQuery).toBe('bravo');
await waitFor(() => {
expect(result.current.debouncedSearchQuery).toBe('bravo');
});
expect(result.current.isSearchActive).toBe(true);
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['b']);
});
it('setSearchQuery + debounce narrows filteredItems', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
searchDebounceMs: 0,
}),
{ wrapper: renderInRoute(['/items/a']) },
);
act(() => {
result.current.setSearchQuery('cha');
});
// searchQuery (raw) flips instantly for the input value.
expect(result.current.searchQuery).toBe('cha');
await waitFor(() => {
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['c']);
});
// Current id 'a' is no longer in the subset → -1 / null neighbours.
expect(result.current.currentIndex).toBe(-1);
expect(result.current.prevId).toBeNull();
expect(result.current.nextId).toBeNull();
});
it('clearSearchQuery resets the value and restores the full subset', async () => {
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'a',
defaultSearchQuery: 'pha',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
searchDebounceMs: 0,
}),
{ wrapper: renderInRoute(['/items/a']) },
);
await waitFor(() => {
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['a']);
});
act(() => {
result.current.clearSearchQuery();
});
expect(result.current.searchQuery).toBe('');
await waitFor(() => {
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['a', 'b', 'c']);
});
});
it('combines URL filter and local search with AND semantics', async () => {
// URL ?q=a narrows to Alpha + Bravo + Charlie. Local "bra" then keeps
// only Bravo. Without AND we'd see Alpha (matches `a`) or Bravo+Charlie
// (matches local in isolation).
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
searchDebounceMs: 0,
}),
{ wrapper: renderInRoute(['/items/b?q=a']) },
);
await waitFor(() => {
expect(result.current.debouncedFilter).toBe('a');
});
act(() => {
result.current.setSearchQuery('bra');
});
await waitFor(() => {
expect(result.current.filteredItems.map((item) => item.id)).toEqual(['b']);
});
});
});
describe('useDetailNavigation — local search (controlled)', () => {
it('respects the controlled searchQuery and fires onSearchQueryChange', () => {
const onSearchQueryChange = vi.fn();
const { rerender, result } = renderHook(
({ searchQuery }: { searchQuery: string }) =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onSearchQueryChange,
searchDebounceMs: 0,
searchQuery,
}),
{
initialProps: { searchQuery: 'bravo' },
wrapper: renderInRoute(['/items/b']),
},
);
expect(result.current.searchQuery).toBe('bravo');
act(() => {
result.current.setSearchQuery('charlie');
});
// Parent owns the value — controller does not flip without a prop change.
expect(result.current.searchQuery).toBe('bravo');
// But the consumer is told to update.
expect(onSearchQueryChange).toHaveBeenLastCalledWith('charlie');
rerender({ searchQuery: 'charlie' });
expect(result.current.searchQuery).toBe('charlie');
});
it('fires onSearchQueryChange in uncontrolled mode too', () => {
const onSearchQueryChange = vi.fn();
const { result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
onSearchQueryChange,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
act(() => {
result.current.setSearchQuery('alpha');
});
expect(onSearchQueryChange).toHaveBeenLastCalledWith('alpha');
expect(result.current.searchQuery).toBe('alpha');
});
});
describe('useDetailNavigation — local search identity stability', () => {
it('keeps setSearchQuery / clearSearchQuery identity-stable across re-renders', () => {
const { rerender, result } = renderHook(
() =>
useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
}),
{ wrapper: renderInRoute(['/items/b']) },
);
const firstSet = result.current.setSearchQuery;
const firstClear = result.current.clearSearchQuery;
rerender();
expect(result.current.setSearchQuery).toBe(firstSet);
expect(result.current.clearSearchQuery).toBe(firstClear);
});
});
describe('useDetailNavigation — navigation back to list does not leak the filter', () => {
it('keeps the URL `?q=` intact after a navigate inside the same router', async () => {
const Harness = () => {
const navigate = useNavigate();
const nav = useDetailNavigation<Item>({
currentId: 'b',
getHref,
getLabel,
getSearchableText,
items: ITEMS,
});
return { nav, navigate };
};
const { result } = renderHook(() => Harness(), {
wrapper: renderInRoute(['/items/b?q=alpha']),
});
await waitFor(() => {
expect(result.current.nav.debouncedFilter).toBe('alpha');
});
// Navigating to another detail page should not alter the filter
// value the hook observes — the URL still carries it.
act(() => {
result.current.navigate('/items/a?q=alpha');
});
await waitFor(() => {
expect(result.current.nav.debouncedFilter).toBe('alpha');
});
});
});
@@ -0,0 +1,394 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDebounce } from 'use-debounce';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { useTableQueryFilterReader } from '@/hooks/use-table-query-filter';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
import { useNavigation } from './use-navigation';
/**
* Default debounce for the in-sheet local search. Shorter than the URL filter's
* 200 ms (`useTableQueryFilterReader`) — local search doesn't round-trip
* through `history` / re-render the route subtree, so we can afford to react
* faster while still coalescing burst typing.
*/
const DEFAULT_SEARCH_DEBOUNCE_MS = 150;
/**
* Headless controller for a detail page that walks a filtered list.
*
* Owns:
* - the filtered/sorted subset (pure `computeNavigation`), narrowed by
* the URL filter AND an optional in-controller local search,
* - the resolved Prev / Next sibling ids and the pre-formatted position
* label (so leaf components don't recompute),
* - the sheet open state (controllable via `open` / `onOpenChange`),
* - the local search query state (controllable via `searchQuery` /
* `onSearchQueryChange`), with its own debounce independent of the URL,
* - navigation actions that thread the current `?<filter>=` into every
* prev / next / item-select destination.
*
* All callbacks are identity-stable across renders that don't change the
* inputs the action depends on — `<DetailNavigationButtons>`,
* `<DetailNavigationSheet>`, and any custom chrome rendered against the
* controller can rely on referential equality for downstream memos.
*/
export interface DetailNavigationController<T extends { id: string }> {
/** Reset the local search to an empty string. Convenience for "X" buttons. */
clearSearchQuery: () => void;
closeSheet: () => void;
/** Active `currentId` coerced to a string, or `null` when absent. */
currentId: null | string;
/** Index of `currentItem` inside `filteredItems`. `-1` when not in subset. */
currentIndex: number;
/** Item matching `currentId` inside the filtered subset, or `null`. */
currentItem: null | T;
/** Debounced URL filter the controller is filtering against. */
debouncedFilter: string;
/**
* Debounced version of {@link searchQuery} — this is what actually narrows
* `filteredItems`. Combined with `debouncedFilter` via AND.
*/
debouncedSearchQuery: string;
/** Sorted+filtered subset that drives prev / next / sheet listing. */
filteredItems: readonly T[];
/** Stable id accessor (defaults to `item.id` when not supplied). */
getId: (item: T) => string;
getLabel: (item: T) => string;
/**
* Resolved haystack accessor — the caller-supplied `getSearchableText`,
* or `getLabel` as a fallback. Exposed for advanced consumers; leaf
* components don't read it because filtering already happened on the
* way into `filteredItems`.
*/
getSearchableText: (item: T) => null | string | undefined;
goToNext: () => void;
goToPrev: () => void;
/** Navigate to the given item and close the sheet. */
handleItemSelect: (item: T) => void;
/** `true` iff `filteredItems.length > 0`. */
hasEntries: boolean;
/** `true` iff `debouncedSearchQuery` is non-empty (UI hints / clear button). */
isSearchActive: boolean;
isSheetOpen: boolean;
/**
* `true` iff the raw `items` array is empty (pre-filter). The convenience
* `<DetailNavigationToolbar>` uses this to render `null` on a fresh detail
* mount when the provider's list hasn't arrived yet.
*/
itemsEmpty: boolean;
/** ID of the next filtered sibling, or `null` at the end / off-subset. */
nextId: null | string;
openSheet: () => void;
/** Pre-formatted `"3/10"` or `"/0"` for the position trigger. */
positionLabel: string;
/** ID of the previous filtered sibling, or `null` at the start / off-subset. */
prevId: null | string;
/**
* Current local search value (raw, **not** debounced) — bind directly to
* an `<input value={...}>` for instant caret feedback. The debounced
* mirror is what filters the list, see {@link debouncedSearchQuery}.
*/
searchQuery: string;
setSearchQuery: (value: string) => void;
setSheetOpen: (open: boolean) => void;
/** Same as `filteredItems.length`, named explicitly for clarity. */
total: number;
}
interface UseDetailNavigationOptions<T extends { id: string }> {
currentId: null | string | undefined;
/** Initial value for the uncontrolled case. Defaults to `false`. */
defaultOpen?: boolean;
/** Initial local search value for the uncontrolled case. Defaults to `''`. */
defaultSearchQuery?: string;
getHref: (item: T) => string;
getId?: (item: T) => string;
getLabel: (item: T) => string;
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
onOpenChange?: (open: boolean) => void;
/**
* Fires on every `setSearchQuery` call — useful when the consumer wants
* to mirror the value into URL params, localStorage, or analytics. Like
* `onOpenChange`, it fires in both controlled and uncontrolled mode.
*/
onSearchQueryChange?: (query: string) => void;
/**
* Controlled-mode opt-in for the sheet. When `open` is `undefined` the
* controller owns the state internally; when a value is provided the
* caller owns it. `onOpenChange` always fires so a fully-controlled
* consumer can observe every set.
*
* Mirrors the `useControllable` pattern from
* `@/components/ui/autocomplete.tsx`.
*/
open?: boolean;
/**
* Debounce (ms) applied between the raw `searchQuery` typed by the user
* and the `debouncedSearchQuery` that actually filters `filteredItems`.
* Defaults to {@link DEFAULT_SEARCH_DEBOUNCE_MS}. Pass `0` for instant
* filtering on small lists.
*/
searchDebounceMs?: number;
/**
* Controlled-mode opt-in for the local search query — same `undefined`
* → uncontrolled / value → controlled convention as `open`. Use this
* when the consumer wants to surface the search outside the sheet (e.g.
* a page-level search box that also drives sibling navigation).
*/
searchQuery?: string;
sortFn?: (a: T, b: T) => number;
}
// Module-level so the reference is stable across renders. Typed against
// the wider `{ id: string }` so it is assignable to `(item: T) => string`
// for any `T extends { id: string }` via function-parameter contravariance
// — no cast at the call site.
const defaultGetId = (item: { id: string }): string => item.id;
/**
* Build the headless `DetailNavigationController<T>` for a detail page.
*
* Bundles the three moving parts every detail page repeats:
* 1. subscribing to the URL filter through `useTableQueryFilterReader`
* (read-only — the detail page never mutates the filter from here),
* 2. running the pure `useNavigation` core against the filtered subset,
* 3. wiring identity-stable `goToPrev` / `goToNext` / `handleItemSelect`
* with `?<filter>=` forwarded through `mergeHrefWithSearchParams`.
*
* The returned controller drives `<DetailNavigationToolbar>`,
* `<DetailNavigationButtons>`, and `<DetailNavigationSheet>` (or any custom
* chrome a consumer wants to render). All function fields are wrapped in
* `useCallback` with `useLatestRef`-stabilized closures over `useSearchParams`
* and the caller-supplied accessors — React Router v6 returns a fresh
* `URLSearchParams` each render, and threading it through callback deps would
* defeat the entire memoization goal.
*
* Pass per-feature callbacks through `useCallback` (or, as the existing
* `use-flow-detail-navigation` / `use-template-detail-navigation` /
* `use-knowledge-detail-navigation` do, hoist them to module scope). An
* inline arrow would still work — `useLatestRef` reads the most recent
* version at fire time — but it forfeits `useNavigation`'s internal
* memoization on `getSearchableText`.
*/
export function useDetailNavigation<T extends { id: string }>({
currentId,
defaultOpen,
defaultSearchQuery,
getHref,
getId,
getLabel,
getSearchableText,
items,
onOpenChange,
onSearchQueryChange,
open,
searchDebounceMs,
searchQuery,
sortFn,
}: UseDetailNavigationOptions<T>): DetailNavigationController<T> {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { debouncedFilter } = useTableQueryFilterReader();
// Memoize so passing `getId={undefined}` keeps the reference stable when
// the caller re-renders for unrelated reasons. Without the memo the
// downstream `useNavigation` memo would invalidate on every render.
const resolvedGetId = useMemo<(item: T) => string>(() => getId ?? defaultGetId, [getId]);
const resolvedGetSearchableText = useMemo<(item: T) => null | string | undefined>(
() => getSearchableText ?? getLabel,
[getSearchableText, getLabel],
);
// Controllable local search (same pattern as the sheet's `open` below).
// Uncontrolled is the common case — most consumers want the controller to
// own the value so the in-sheet input "just works". Controlled callers
// (e.g. a page that mirrors the value to its own URL param) pass
// `searchQuery` and observe via `onSearchQueryChange`.
const onSearchQueryChangeRef = useLatestRef(onSearchQueryChange);
const [internalSearchQuery, setInternalSearchQuery] = useState(defaultSearchQuery ?? '');
const isSearchControlled = searchQuery !== undefined;
const activeSearchQuery = isSearchControlled ? searchQuery : internalSearchQuery;
const [debouncedSearchQuery] = useDebounce(activeSearchQuery, searchDebounceMs ?? DEFAULT_SEARCH_DEBOUNCE_MS);
const setSearchQuery = useCallback(
(next: string) => {
if (!isSearchControlled) {
setInternalSearchQuery(next);
}
onSearchQueryChangeRef.current?.(next);
},
[isSearchControlled, onSearchQueryChangeRef],
);
const clearSearchQuery = useCallback(() => setSearchQuery(''), [setSearchQuery]);
// Stabilise the query tuple so `useNavigation`'s `useMemo` only refires
// when either source actually moved. Inline `[a, b]` per render would
// defeat the memo even when both strings are unchanged.
const queryTerms = useMemo(
() => [debouncedFilter, debouncedSearchQuery] as const,
[debouncedFilter, debouncedSearchQuery],
);
const { currentIndex, currentItem, filteredItems, nextId, prevId, total } = useNavigation<T>({
currentId,
getId: resolvedGetId,
getSearchableText: resolvedGetSearchableText,
items,
query: queryTerms,
sortFn,
});
// Controlled-mode sheet state (mirrors `useControllable` from
// `components/ui/autocomplete.tsx:27-46`). When `open` is provided the
// caller owns the state; otherwise the controller owns it.
// `onOpenChange` fires on every set so fully-controlled consumers can
// observe transitions.
const onOpenChangeRef = useLatestRef(onOpenChange);
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
const isOpenControlled = open !== undefined;
const isSheetOpen = isOpenControlled ? open : internalOpen;
const setSheetOpen = useCallback(
(next: boolean) => {
if (!isOpenControlled) {
setInternalOpen(next);
}
onOpenChangeRef.current?.(next);
},
[isOpenControlled, onOpenChangeRef],
);
const openSheet = useCallback(() => setSheetOpen(true), [setSheetOpen]);
const closeSheet = useCallback(() => setSheetOpen(false), [setSheetOpen]);
// `useSearchParams` from React Router v6 returns a fresh `URLSearchParams`
// every render. Threading it (or any caller-supplied accessor that might
// not be stable) through `useCallback` deps would invalidate every
// navigation callback on every render. Stash through `useLatestRef` and
// read at fire-time instead — handlers fire from user clicks / keyboard
// events, so the one-commit lag documented on `useLatestRef` never bites.
const searchParamsRef = useLatestRef(searchParams);
const getHrefRef = useLatestRef(getHref);
const getIdRef = useLatestRef(resolvedGetId);
const filteredItemsRef = useLatestRef(filteredItems);
const buildHref = useCallback(
(item: T) => mergeHrefWithSearchParams(getHrefRef.current(item), searchParamsRef.current),
[getHrefRef, searchParamsRef],
);
const handleItemSelect = useCallback(
(item: T) => {
// Close the sheet *before* navigating — preserves the pre-refactor
// ordering so a route change can't unmount the sheet while its
// close callback is still in flight.
setSheetOpen(false);
navigate(buildHref(item), { replace: true });
},
[buildHref, navigate, setSheetOpen],
);
const goTo = useCallback(
(id: null | string) => {
if (!id) {
return;
}
const target = filteredItemsRef.current.find((item) => String(getIdRef.current(item)) === id);
if (!target) {
return;
}
navigate(buildHref(target), { replace: true });
},
[buildHref, filteredItemsRef, getIdRef, navigate],
);
const goToPrev = useCallback(() => goTo(prevId), [goTo, prevId]);
const goToNext = useCallback(() => goTo(nextId), [goTo, nextId]);
const positionLabel = useMemo(
() => (total === 0 || currentIndex === -1 ? `/${total}` : `${currentIndex + 1}/${total}`),
[currentIndex, total],
);
const normalizedCurrentId = currentId != null ? String(currentId) : null;
const hasEntries = filteredItems.length > 0;
const itemsEmpty = items.length === 0;
const isSearchActive = debouncedSearchQuery.length > 0;
return useMemo<DetailNavigationController<T>>(
() => ({
clearSearchQuery,
closeSheet,
currentId: normalizedCurrentId,
currentIndex,
currentItem,
debouncedFilter,
debouncedSearchQuery,
filteredItems,
getId: resolvedGetId,
getLabel,
getSearchableText: resolvedGetSearchableText,
goToNext,
goToPrev,
handleItemSelect,
hasEntries,
isSearchActive,
isSheetOpen,
itemsEmpty,
nextId,
openSheet,
positionLabel,
prevId,
searchQuery: activeSearchQuery,
setSearchQuery,
setSheetOpen,
total,
}),
[
activeSearchQuery,
clearSearchQuery,
closeSheet,
normalizedCurrentId,
currentIndex,
currentItem,
debouncedFilter,
debouncedSearchQuery,
filteredItems,
resolvedGetId,
getLabel,
resolvedGetSearchableText,
goToNext,
goToPrev,
handleItemSelect,
hasEntries,
isSearchActive,
isSheetOpen,
itemsEmpty,
nextId,
openSheet,
positionLabel,
prevId,
setSearchQuery,
setSheetOpen,
total,
],
);
}
@@ -0,0 +1,337 @@
import { describe, expect, it } from 'vitest';
import { computeNavigation } from './use-navigation';
interface Row {
id: string;
title: string;
}
const getId = (row: Row) => row.id;
const getTitle = (row: Row) => row.title;
const ROWS: readonly Row[] = [
{ id: 'a', title: 'Alpha' },
{ id: 'b', title: 'Bravo' },
{ id: 'c', title: 'Charlie' },
{ id: 'd', title: 'Delta' },
] as const;
describe('computeNavigation', () => {
it('returns prev/next neighbours for a middle item', () => {
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(2);
expect(result.prevId).toBe('b');
expect(result.nextId).toBe('d');
expect(result.total).toBe(4);
});
it('returns null prev for the first item', () => {
const result = computeNavigation({
currentId: 'a',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(0);
expect(result.prevId).toBeNull();
expect(result.nextId).toBe('b');
});
it('returns null next for the last item', () => {
const result = computeNavigation({
currentId: 'd',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(3);
expect(result.prevId).toBe('c');
expect(result.nextId).toBeNull();
});
it('reports currentIndex=-1 when the current item is missing from the filtered subset', () => {
const result = computeNavigation({
currentId: 'zzz',
getId,
items: ROWS,
});
expect(result.currentIndex).toBe(-1);
expect(result.currentItem).toBeNull();
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
expect(result.total).toBe(4);
});
it('reports currentIndex=-1 when currentId is null or undefined', () => {
const nullResult = computeNavigation({
currentId: null,
getId,
items: ROWS,
});
expect(nullResult.currentIndex).toBe(-1);
expect(nullResult.prevId).toBeNull();
expect(nullResult.nextId).toBeNull();
const undefinedResult = computeNavigation({
currentId: undefined,
getId,
items: ROWS,
});
expect(undefinedResult.currentIndex).toBe(-1);
});
it('honours `query` when narrowing the subset', () => {
const result = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
items: ROWS,
// Substring "c" matches "Charlie" (case-insensitive).
query: 'c',
});
expect(result.filteredItems.map(getId)).toEqual(['c']);
expect(result.currentIndex).toBe(0);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
expect(result.total).toBe(1);
});
it('drops the current item from the result when it does not match the query', () => {
const result = computeNavigation({
currentId: 'a',
getId,
getSearchableText: getTitle,
items: ROWS,
query: 'Bravo',
});
expect(result.filteredItems.map(getId)).toEqual(['b']);
expect(result.currentIndex).toBe(-1);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
});
it('treats an empty/undefined query as "no filter" even when getSearchableText is provided', () => {
const empty = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
items: ROWS,
query: '',
});
expect(empty.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
const missing = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
items: ROWS,
});
expect(missing.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
});
it('skips filtering silently when query is non-empty but getSearchableText is missing', () => {
// Documents the boundary: without a haystack accessor we cannot evaluate
// the query against rows, so we degrade to "no filter" instead of
// throwing — keeps the hook usable while a caller forgets to wire one up.
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
query: 'pha',
});
expect(result.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
});
it('preserves input order when no sortFn is provided', () => {
const reversed = [...ROWS].reverse();
const result = computeNavigation({
currentId: 'c',
getId,
items: reversed,
});
expect(result.filteredItems.map(getId)).toEqual(['d', 'c', 'b', 'a']);
expect(result.currentIndex).toBe(1);
expect(result.prevId).toBe('d');
expect(result.nextId).toBe('b');
});
it('applies sortFn to the filtered subset', () => {
const result = computeNavigation({
currentId: 'b',
getId,
items: ROWS,
sortFn: (a, b) => b.title.localeCompare(a.title),
});
expect(result.filteredItems.map(getId)).toEqual(['d', 'c', 'b', 'a']);
expect(result.currentIndex).toBe(2);
expect(result.prevId).toBe('c');
expect(result.nextId).toBe('a');
});
it('handles an empty items array', () => {
const result = computeNavigation({
currentId: 'anything',
getId,
items: [],
});
expect(result.filteredItems).toEqual([]);
expect(result.total).toBe(0);
expect(result.currentIndex).toBe(-1);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
});
it('returns the current item when present', () => {
const result = computeNavigation({
currentId: 'c',
getId,
items: ROWS,
});
expect(result.currentItem).toEqual({ id: 'c', title: 'Charlie' });
});
it('does not mutate the input array even when sorting', () => {
const before = ROWS.map(getId);
computeNavigation({
currentId: 'a',
getId,
items: ROWS,
sortFn: (a, b) => b.title.localeCompare(a.title),
});
expect(ROWS.map(getId)).toEqual(before);
});
it('handles "item deleted, currentId still points to it"', () => {
// The user was viewing item `b`, then `b` was deleted (removed from
// `items`) — currentId stays `b` until the route updates. The hook
// must surface this as currentIndex=-1 / no neighbours so the UI
// disables Prev/Next rather than jumping to an unrelated row.
const trimmed = ROWS.filter((row) => row.id !== 'b');
const result = computeNavigation({
currentId: 'b',
getId,
items: trimmed,
});
expect(result.filteredItems.map(getId)).toEqual(['a', 'c', 'd']);
expect(result.currentIndex).toBe(-1);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
expect(result.total).toBe(3);
});
it('folds diacritics + case the same way the list filter does', () => {
const accented: readonly Row[] = [
{ id: '1', title: 'Café' },
{ id: '2', title: 'naïve' },
{ id: '3', title: 'resume' },
];
const result = computeNavigation({
currentId: '1',
getId,
getSearchableText: getTitle,
items: accented,
query: 'cafe',
});
expect(result.filteredItems.map(getId)).toEqual(['1']);
expect(result.currentIndex).toBe(0);
});
it('treats an empty array query as "no filter"', () => {
const result = computeNavigation({
currentId: 'c',
getId,
getSearchableText: getTitle,
items: ROWS,
query: [],
});
expect(result.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
expect(result.currentIndex).toBe(2);
});
it('ANDs every non-empty term when `query` is an array', () => {
// Both terms must match the haystack — substring "a" matches Alpha,
// Bravo, Charlie; substring "ph" then narrows to just Alpha.
const result = computeNavigation({
currentId: 'a',
getId,
getSearchableText: getTitle,
items: ROWS,
query: ['a', 'ph'],
});
expect(result.filteredItems.map(getId)).toEqual(['a']);
expect(result.currentIndex).toBe(0);
expect(result.prevId).toBeNull();
expect(result.nextId).toBeNull();
});
it('drops empty strings from an array query without throwing', () => {
// Callers can pass `[urlFilter, localSearch]` straight through; either
// one being empty must not turn the whole filter off — the other term
// is still expected to narrow.
const result = computeNavigation({
currentId: 'b',
getId,
getSearchableText: getTitle,
items: ROWS,
query: ['', 'bravo', ''],
});
expect(result.filteredItems.map(getId)).toEqual(['b']);
});
it('falls back to "no filter" when every array term is empty', () => {
const result = computeNavigation({
currentId: 'a',
getId,
getSearchableText: getTitle,
items: ROWS,
query: ['', '', ''],
});
expect(result.filteredItems.map(getId)).toEqual(['a', 'b', 'c', 'd']);
});
it('matches numeric item ids with string currentId', () => {
type NumRow = { id: number; title: string };
const rows: readonly NumRow[] = [
{ id: 10, title: 'A' },
{ id: 20, title: 'B' },
{ id: 30, title: 'C' },
];
const getNumericId = (row: NumRow) => String(row.id);
const result = computeNavigation({
currentId: '20',
getId: getNumericId,
items: rows,
});
expect(result.currentIndex).toBe(1);
expect(result.prevId).toBe('10');
expect(result.nextId).toBe('30');
});
});
@@ -0,0 +1,168 @@
import { useMemo } from 'react';
import { createTextMatcher } from './text-filter';
interface NavigationInput<T> {
currentId: null | string | undefined;
getId: (item: T) => string;
/**
* Optional accessor for the text the filter runs against. Only consulted
* when `query` is non-empty — pages with no filter don't need it.
*/
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
/**
* Free-text filter(s) to narrow the navigable subset. Accepts:
* - `undefined` / empty string / empty array → no filter, every item passes;
* - a single string → substring match (case + diacritic insensitive);
* - a `readonly string[]` → AND of every non-empty entry. Empty strings
* inside the array are ignored, so callers can pass
* `[urlFilter, localSearch]` without checking emptiness at the call site.
*
* Matching reuses `createTextMatcher`, so behaviour stays in lockstep with
* the list page's column filter regardless of how many sources combine here.
*/
query?: readonly string[] | string;
sortFn?: (a: T, b: T) => number;
}
interface NavigationResult<T> {
currentIndex: number;
currentItem: null | T;
filteredItems: readonly T[];
nextId: null | string;
prevId: null | string;
total: number;
}
/**
* Pure core of {@link useNavigation}: filter, sort, and resolve Prev/Next
* around `currentId`. Exposed without React so the algorithm can be
* unit-tested directly — the hook is a thin `useMemo` wrapper around this.
*
* The Map of `id → index` is built once per invocation so the `currentId`
* lookup is O(1) instead of an `Array.find` per render. The map is
* intentionally not exposed — callers should walk `filteredItems` or use the
* returned `prevId` / `nextId`.
*/
export const computeNavigation = <T>({
currentId,
getId,
getSearchableText,
items,
query,
sortFn,
}: NavigationInput<T>): NavigationResult<T> => {
// Normalise `string | string[] | undefined` to a list of non-empty terms.
// Empties are dropped here once so the hot `items.filter` below never
// re-checks them — and callers can splice `[urlFilter, localSearch]`
// straight through without short-circuiting at the call site.
const queryTerms = query === undefined ? [] : Array.isArray(query) ? query : [query as string];
const activeTerms = queryTerms.filter((term): term is string => term.length > 0);
const hasQuery = activeTerms.length > 0;
// Build each matcher once at the top of the filter pass — `createTextMatcher`
// normalises the term, so doing it inside the per-item loop would burn
// O(items × terms) on the same work.
const matchers = hasQuery && getSearchableText ? activeTerms.map(createTextMatcher) : null;
const filtered =
matchers && getSearchableText
? items.filter((item) => {
const text = getSearchableText(item);
return matchers.every((match) => match(text));
})
: items;
const ordered = sortFn ? [...filtered].sort(sortFn) : filtered;
const indexById = new Map<string, number>();
ordered.forEach((item, index) => {
// Apollo commonly hydrates GraphQL `ID` fields as numbers even though
// route params are strings — normalize so lookups stay stable.
indexById.set(String(getId(item)), index);
});
const currentIndex = currentId != null ? (indexById.get(String(currentId)) ?? -1) : -1;
if (currentIndex === -1) {
return {
currentIndex: -1,
currentItem: null,
filteredItems: ordered,
nextId: null,
prevId: null,
total: ordered.length,
};
}
const prevItem = currentIndex > 0 ? ordered[currentIndex - 1] : null;
const nextItem = currentIndex < ordered.length - 1 ? ordered[currentIndex + 1] : null;
return {
currentIndex,
currentItem: ordered[currentIndex] ?? null,
filteredItems: ordered,
nextId: nextItem ? String(getId(nextItem)) : null,
prevId: prevItem ? String(getId(prevItem)) : null,
total: ordered.length,
};
};
interface UseNavigationOptions<T> {
currentId: null | string | undefined;
getId: (item: T) => string;
/**
* Accessor for the text the filter runs against. Only required when
* `query` is non-empty.
*/
getSearchableText?: (item: T) => null | string | undefined;
items: readonly T[];
/**
* Free-text filter. Accepts a single string or an array (AND of every
* non-empty entry). Empty / `undefined` / `[]` → unfiltered. See
* {@link NavigationInput.query} for details.
*/
query?: readonly string[] | string;
/**
* Optional comparator. When omitted, the input array order is preserved —
* the navigation walks `items` exactly as the caller arranged them, which
* matches what list pages already render.
*/
sortFn?: (a: T, b: T) => number;
}
type UseNavigationResult<T> = NavigationResult<T>;
/**
* Resolve Prev/Next siblings for a detail page that's tied to a filtered list.
*
* The list page holds the same `items` and free-text `query`, so this hook
* produces the same ordering as what the user sees in the table — Prev/Next
* stays in lockstep with the filter even after the user lands on a detail
* URL via a shared link.
*
* If the current item is missing from the filtered subset (deleted, or no
* longer matches the filter), `currentIndex` is `-1` and both `prevId` /
* `nextId` are `null` — the caller renders disabled Prev/Next buttons in that
* case rather than navigating into an unrelated neighbour.
*
* Callers pass *data* (`query`, `getSearchableText`) rather than a prebuilt
* predicate. That removes the identity-stability footgun the predicate-based
* shape had: a fresh-arrow `(item) => …` callback every render would have
* defeated the internal `useMemo`. With this shape, only `query` flips per
* keystroke, and `getSearchableText` is naturally module-scoped at the
* feature level (see `use-flow-detail-navigation` etc.).
*/
export function useNavigation<T>({
currentId,
getId,
getSearchableText,
items,
query,
sortFn,
}: UseNavigationOptions<T>): UseNavigationResult<T> {
return useMemo(
() => computeNavigation({ currentId, getId, getSearchableText, items, query, sortFn }),
[currentId, getId, getSearchableText, items, query, sortFn],
);
}
@@ -0,0 +1,172 @@
import { render, waitFor } from '@testing-library/react';
import { createMemoryRouter, Outlet, RouterProvider } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { apolloTitle } from '@/lib/route-titles/apollo-title';
import { DocumentTitle } from './document-title';
const renderAt = (initialPath: string, routes: Parameters<typeof createMemoryRouter>[0]) => {
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
return render(<RouterProvider router={router} />);
};
describe('DocumentTitle', () => {
it('renders APP_NAME when no matched route exposes a title handle', async () => {
renderAt('/anywhere', [
{
// No child route handle — DocumentTitle should fall back to APP_NAME only.
children: [{ element: <span>page</span>, path: 'anywhere' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('PentAGI'));
});
it('renders a static title from handle', async () => {
renderAt('/dashboard', [
{
children: [{ element: <span>page</span>, handle: { title: 'Dashboard' }, path: 'dashboard' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('Dashboard — PentAGI'));
});
it('renders a derived title from a handle.title function reading params', async () => {
renderAt('/prompts/agentSelector', [
{
children: [
{
element: <span>page</span>,
handle: {
title: ({ promptId }: Record<string, string | undefined>) =>
promptId
? promptId.replaceAll(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())
: 'Prompt',
},
path: 'prompts/:promptId',
},
],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('Agent Selector — PentAGI'));
});
it('renders the deepest matching handle (child wins over parent)', async () => {
renderAt('/settings/api-tokens', [
{
children: [
{
children: [
{ element: <span>tokens</span>, handle: { title: 'API tokens' }, path: 'api-tokens' },
],
element: <Outlet />,
handle: { title: 'Settings' },
path: 'settings',
},
],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('API tokens — PentAGI'));
});
it('renders a title component produced by apolloTitle()', async () => {
// End-to-end check that the public factory wires the marker correctly
// and `DocumentTitle` recognizes it. A stubbed `useQuery` returns
// canned data so the test is hermetic.
const CustomTitle = apolloTitle<{ name: string }, Record<string, never>>({
select: (data, { id }) => `Custom #${id} ${data?.name ?? ''}`.trim(),
// Minimal stub — only `data` is read downstream.
useQuery: (() => ({ data: { name: 'thing' } })) as never,
variables: () => ({}),
});
renderAt('/items/42', [
{
children: [{ element: <span>page</span>, handle: { title: CustomTitle }, path: 'items/:id' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('Custom #42 thing — PentAGI'));
});
it('treats an unmarked function as a plain resolver, not a component', async () => {
// Without the marker, DocumentTitle calls the function with params and
// wraps the returned string with the standard "X — PentAGI" template.
const resolveTitle = (params: Record<string, string | undefined>) => `Item ${params.id}`;
renderAt('/items/7', [
{
children: [{ element: <span>page</span>, handle: { title: resolveTitle }, path: 'items/:id' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('Item 7 — PentAGI'));
});
it('falls back to APP_NAME when handle.title returns an empty string', async () => {
renderAt('/x', [
{
children: [{ element: <span>page</span>, handle: { title: '' }, path: 'x' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
// An empty string from the resolver is treated as "no title" — fall back
// to APP_NAME alone. This guards the route-level convention: pages that
// do not want a prefix can return '' instead of omitting the handle.
await waitFor(() => expect(document.title).toBe('PentAGI'));
});
});
@@ -0,0 +1,61 @@
import type { ComponentType } from 'react';
import { useMatches } from 'react-router-dom';
import { isApolloTitle } from '@/lib/route-titles/apollo-title';
import { renderTitle, type RouteParams } from '@/lib/route-titles/render-title';
type TitleComponent = ComponentType<{ params: RouteParams }>;
type TitleResolver = ((params: RouteParams) => string) | string | TitleComponent;
const hasTitle = (handle: unknown): handle is { title: TitleResolver } => {
if (typeof handle !== 'object' || handle === null || !('title' in handle)) {
return false;
}
const value = (handle as { title: unknown }).title;
return typeof value === 'string' || typeof value === 'function';
};
/**
* Document `<title>` driver. Walks `useMatches()` for the deepest route
* exposing `handle.title` and renders accordingly:
*
* handle: { title: 'Dashboard' } // static
* handle: { title: (p) => formatPromptId(p.promptId!) } // params-derived
* handle: { title: FlowTitle } // reactive (Apollo)
*
* Living in the app shell, this component survives navigation between
* sibling detail routes — no flash of a generic fallback during the
* destination page's data fetch.
*
* IMPORTANT: do not render `<title>` anywhere else in the tree. React 19
* hoists every `<title>` into `<head>`, and multiple simultaneous titles
* lead to undefined browser/SEO behavior. The only safe `<title>` outside
* this module is inside `<svg>` (icons), which React treats as SVG-title.
* See https://react.dev/reference/react-dom/components/title.
*/
export function DocumentTitle() {
const matches = useMatches();
const match = matches.findLast((m) => hasTitle(m.handle));
if (!match || !hasTitle(match.handle)) {
return renderTitle(null);
}
const value = match.handle.title;
if (typeof value === 'string') {
return renderTitle(value);
}
if (isApolloTitle(value)) {
const TitleComp = value;
return <TitleComp params={match.params} />;
}
return renderTitle(value(match.params));
}
@@ -0,0 +1,219 @@
import { ClipboardCopy, Copy, Download, FileSymlink, FolderOutput, Trash2 } from 'lucide-react';
import type { FileManagerAction, FileManagerBulkAction, FileNode } from './file-manager-types';
/**
* Built-in download action.
*
* The `getDownloadHref` callback receives the file node and must return a fully-formed
* URL. For directories the browser is hinted to save the response as `${name}.zip` —
* the backend is responsible for actually returning a zip stream.
*/
export const downloadAction = (
getDownloadHref: (file: FileNode) => string,
options: { directoryArchiveExtension?: string } = {},
): FileManagerAction => {
const archiveExtension = options.directoryArchiveExtension ?? 'zip';
return {
appliesToDirs: true,
getHref: getDownloadHref,
getHrefDownloadAttr: (file) => (file.isDir ? `${file.name}.${archiveExtension}` : file.name),
icon: Download,
id: '__builtin_download',
label: 'Download',
onSelect: () => {},
};
};
/** Built-in copy-path action. */
export const copyPathAction = (onCopyPath: (file: FileNode) => void): FileManagerAction => ({
appliesToDirs: true,
icon: ClipboardCopy,
id: '__builtin_copy_path',
label: 'Copy path',
onSelect: onCopyPath,
});
/**
* Built-in delete action. Always rendered with a leading separator and destructive variant.
* Caller is responsible for showing a confirmation dialog inside `onSelect`.
*/
export const deleteAction = (onDelete: (file: FileNode) => void): FileManagerAction => ({
appliesToDirs: true,
icon: Trash2,
id: '__builtin_delete',
label: 'Delete',
onSelect: onDelete,
separatorBefore: true,
});
// ── Bulk-action helpers ─────────────────────────────────────────────────────
//
// Each helper produces a `FileManagerBulkAction` with sensible defaults; the
// caller passes any callback / config it needs and lets the bar handle the
// rendering, confirmation and dedup. Mirrors the row-action helpers above so
// consumers compose `bulkActions={[bulkXAction(...), bulkYAction(...)]}` the
// same way they compose `actions={[xAction(...), yAction(...)]}`.
interface BulkDeleteOptions {
/** Confirm-dialog body formatter. Default: "This will delete N items. This action cannot be undone." */
confirmDescription?: (countLabel: string) => string;
/** Confirm-button label. Default: action `label`. */
confirmText?: string;
/** Confirm-dialog title formatter. Default: "Delete N items". */
confirmTitle?: (countLabel: string) => string;
/** Trigger label in the bar. Default: "Delete". */
label?: string;
}
/**
* Built-in bulk delete. Wraps the destructive variant + confirm dialog so callers
* just supply the API call. Always shown as a standalone (non-overflow) button so
* the destructive intent stays visible.
*/
export const bulkDeleteAction = (
onDelete: (files: FileNode[]) => Promise<void> | void,
options: BulkDeleteOptions = {},
): FileManagerBulkAction => {
const label = options.label ?? 'Delete';
return {
confirm: {
confirmText: options.confirmText ?? label,
description:
options.confirmDescription ??
((countLabel) => `This will delete ${countLabel}. This action cannot be undone.`),
title: options.confirmTitle ?? ((countLabel) => `Delete ${countLabel}`),
},
icon: Trash2,
id: '__builtin_bulk_delete',
label,
onSelect: onDelete,
variant: 'destructive',
};
};
/**
* Built-in "copy paths" bulk action. Joins every selected file's `path` with `\n`
* and hands the resulting string to `onCopy`. Lives in the overflow menu by default
* so it doesn't crowd the bar — pass `overflow: false` to promote it to a button.
*/
export const bulkCopyPathsAction = (
onCopy: (paths: string[]) => Promise<void> | void,
options: { label?: string; overflow?: boolean } = {},
): FileManagerBulkAction => ({
icon: ClipboardCopy,
id: '__builtin_bulk_copy_paths',
label: options.label ?? 'Copy paths',
onSelect: (files) => onCopy(files.map((file) => file.path)),
overflow: options.overflow ?? true,
});
/**
* Built-in "move to…" bulk action. The host opens its own destination picker
* inside `onMove` (we don't ship a path-picker UI inside the file-manager).
*/
export const bulkMoveAction = (
onMove: (files: FileNode[]) => void,
options: { label?: string; overflow?: boolean } = {},
): FileManagerBulkAction => ({
icon: FileSymlink,
id: '__builtin_bulk_move',
label: options.label ?? 'Move to…',
onSelect: onMove,
overflow: options.overflow,
});
/**
* Built-in "copy to…" bulk action. The host opens its own destination picker
* inside `onCopy`.
*/
export const bulkCopyAction = (
onCopy: (files: FileNode[]) => void,
options: { label?: string; overflow?: boolean } = {},
): FileManagerBulkAction => ({
icon: Copy,
id: '__builtin_bulk_copy',
label: options.label ?? 'Copy to…',
onSelect: onCopy,
overflow: options.overflow,
});
/**
* Built-in "save as resource" bulk action used by Flow Files. Identical shape to
* `bulkMoveAction` — the host opens its own promote dialog inside `onPromote`.
*/
export const bulkPromoteAction = (
onPromote: (files: FileNode[]) => void,
options: { label?: string; overflow?: boolean } = {},
): FileManagerBulkAction => ({
icon: FolderOutput,
id: '__builtin_bulk_promote',
label: options.label ?? 'Save as resources',
onSelect: onPromote,
overflow: options.overflow,
});
interface BulkDownloadOptions {
/** Override the suggested filename hint passed to the browser. */
getDownloadName?: (files: FileNode[]) => string;
label?: string;
overflow?: boolean;
}
/**
* Default browser-side filename hint for bulk download. Mirrors what the
* backend will set via `Content-Disposition` so the browser shows a sensible
* name even when the response header is missing or stripped:
*
* - 1 file → original filename
* - 1 directory → `${name}.zip`
* - many entries → `download.zip`
*/
const defaultBulkDownloadName = (files: FileNode[]): string => {
const [single, ...rest] = files;
if (single && rest.length === 0) {
return single.isDir ? `${single.name}.zip` : single.name;
}
return 'download.zip';
};
/**
* Built-in "download" bulk action. The host supplies a function that builds the
* download URL from the selection (typically with `?paths[]=…&paths[]=…`); the
* helper takes care of triggering the actual browser download via a transient
* `<a download>` element. Backend decides the response shape: a single file is
* served as-is, anything else is packaged into a ZIP archive.
*/
export const bulkDownloadAction = (
getDownloadHref: (files: FileNode[]) => string,
options: BulkDownloadOptions = {},
): FileManagerBulkAction => ({
icon: Download,
id: '__builtin_bulk_download',
label: options.label ?? 'Download',
onSelect: (files) => {
if (files.length === 0) {
return;
}
const href = getDownloadHref(files);
// Trigger the download via a transient anchor so the browser respects
// the `download` attribute and the backend's `Content-Disposition`.
// `window.open` would do, but it can be blocked as a popup and doesn't
// honour the filename hint the same way.
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = (options.getDownloadName ?? defaultBulkDownloadName)(files);
anchor.rel = 'noopener';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
},
overflow: options.overflow,
});
@@ -0,0 +1,251 @@
import { Ellipsis, X } from 'lucide-react';
import { type ComponentType, useCallback, useMemo, useState } from 'react';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { FileManagerBulkAction, FileManagerLabels, FileNode } from './file-manager-types';
import { dedupeOverlappingPaths, formatFileSize, pluralizeItemsEnglish } from './file-manager-utils';
interface BulkActionButtonProps {
action: FileManagerBulkAction;
isDisabled: boolean;
onClick: (action: FileManagerBulkAction) => void;
}
interface FileManagerBulkActionsBarProps {
actions: readonly FileManagerBulkAction[];
files: FileNode[];
labels: FileManagerLabels;
onClearSelection: () => void;
selectedPaths: Set<string>;
/** Cumulative byte count of the deduped selection. `0` suppresses the size suffix. */
selectionTotalBytes: number;
}
interface ResolvedAction {
action: FileManagerBulkAction;
isDisabled: boolean;
}
/**
* Footer that appears when at least one row is selected. Drives the cancel
* control plus an arbitrary list of host-supplied bulk actions:
*
* - Each action is rendered as a button (or pushed into the trailing `…`
* overflow menu when `overflow: true`).
* - Actions with a `confirm` config trigger the shared ConfirmationDialog
* before invoking `onSelect`. The dialog state is owned here so each host
* doesn't have to wire its own.
* - The selection list is **deduped** before being handed to `onSelect` so
* a directory never ships together with one of its descendants — the
* caller deletes / moves / etc. only the parent.
*/
export function FileManagerBulkActionsBar({
actions,
files,
labels,
onClearSelection,
selectedPaths,
selectionTotalBytes,
}: FileManagerBulkActionsBarProps) {
const [pendingAction, setPendingAction] = useState<FileManagerBulkAction | null>(null);
// Resolve the deduped FileNode[] once per render — every action callback,
// confirm-dialog title and isDisabled / isHidden predicate share it, so
// recomputing per call would be wasteful.
const dedupedFiles = useMemo(() => {
if (selectedPaths.size === 0) {
return [];
}
const dedupedPathSet = new Set(dedupeOverlappingPaths(selectedPaths));
return files.filter((file) => dedupedPathSet.has(file.path));
}, [files, selectedPaths]);
const visibleActions = useMemo<ResolvedAction[]>(
() =>
actions
.filter((action) => !action.isHidden?.(dedupedFiles))
.map((action) => ({
action,
isDisabled: action.isDisabled?.(dedupedFiles) ?? false,
})),
[actions, dedupedFiles],
);
const inlineActions = useMemo(() => visibleActions.filter((entry) => !entry.action.overflow), [visibleActions]);
const overflowActions = useMemo(() => visibleActions.filter((entry) => entry.action.overflow), [visibleActions]);
const runAction = useCallback(
async (action: FileManagerBulkAction) => {
// `dedupedFiles` is captured at click time — the state used by the
// bar at that moment matches what the host receives, even if the
// selection mutates while an async confirm is open.
await action.onSelect(dedupedFiles);
},
[dedupedFiles],
);
const handleActionClick = useCallback(
(action: FileManagerBulkAction) => {
if (action.confirm) {
setPendingAction(action);
return;
}
void runAction(action);
},
[runAction],
);
const handleConfirm = useCallback(async () => {
if (!pendingAction) {
return;
}
await runAction(pendingAction);
// ConfirmationDialog auto-closes after a fulfilled `handleConfirm`,
// but we own `pendingAction` separately so the dialog tree fully
// unmounts on the next tick.
setPendingAction(null);
}, [pendingAction, runAction]);
const handleConfirmOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) {
setPendingAction(null);
}
}, []);
if (selectedPaths.size === 0 || actions.length === 0) {
return null;
}
const pluralize = labels.pluralizeItems ?? pluralizeItemsEnglish;
const countLabel = pluralize(selectedPaths.size);
const baseSelectedText = labels.selectedLabel?.(selectedPaths.size) ?? `${selectedPaths.size} selected`;
const sizeSuffix = (labels.formatSelectionSize ?? formatFileSize)(selectionTotalBytes);
const selectedText = sizeSuffix ? `${baseSelectedText} · ${sizeSuffix}` : baseSelectedText;
const cancelText = labels.bulkCancel ?? 'Cancel';
const moreActionsText = labels.bulkMoreActions ?? 'More actions';
return (
<>
<div className="bg-background flex flex-wrap items-center gap-2 border-t px-3 py-2">
<span className="text-muted-foreground text-sm">{selectedText}</span>
<div className="ml-auto flex items-center gap-2">
<Button
aria-label={cancelText}
className="max-sm:size-8 max-sm:px-0"
onClick={onClearSelection}
size="sm"
variant="ghost"
>
<X className="sm:hidden" />
<span className="hidden sm:inline">{cancelText}</span>
</Button>
{inlineActions.map(({ action, isDisabled }) => (
<BulkActionButton
action={action}
isDisabled={isDisabled}
key={action.id}
onClick={handleActionClick}
/>
))}
{overflowActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={moreActionsText}
size="icon-sm"
variant="outline"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{overflowActions.map(({ action, isDisabled }) => (
<DropdownMenuItem
className={cn(
action.variant === 'destructive' &&
'text-destructive focus:text-destructive focus:bg-destructive/10',
)}
disabled={isDisabled}
key={action.id}
onSelect={(event) => {
event.preventDefault();
handleActionClick(action);
}}
>
{action.icon ? <action.icon className="size-4" /> : null}
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{pendingAction?.confirm && (
<ConfirmationDialog
confirmText={pendingAction.confirm.confirmText ?? pendingAction.label}
confirmVariant={pendingAction.variant === 'destructive' ? 'destructive' : 'default'}
description={pendingAction.confirm.description?.(countLabel)}
handleConfirm={handleConfirm}
handleOpenChange={handleConfirmOpenChange}
isOpen={!!pendingAction}
title={pendingAction.confirm.title(countLabel)}
/>
)}
</>
);
}
/**
* Inline button for a single non-overflow bulk action. Extracted so the icon-only
* variant (no `label` text on narrow screens) can be added later without bloating
* the parent's JSX.
*/
function BulkActionButton({ action, isDisabled, onClick }: BulkActionButtonProps) {
const Icon = action.icon as ComponentType<{ className?: string }> | undefined;
const button = (
<Button
className={cn(action.icon && 'max-sm:size-8 max-sm:px-0')}
disabled={isDisabled}
onClick={() => onClick(action)}
size="sm"
variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
>
{Icon ? <Icon /> : null}
<span className={cn(action.icon ? 'hidden sm:inline' : undefined)}>{action.label}</span>
</Button>
);
// Tooltip surfaces the label when the inline text is hidden on narrow
// viewports (icon-only mode). On wider screens the label is already visible,
// so the tooltip is a harmless duplicate but still helps with keyboard focus.
if (!action.icon) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{action.label}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,46 @@
import { Fragment, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface FileManagerHighlightedNameProps {
className?: string;
name: string;
query?: string;
}
export function FileManagerHighlightedName({ className, name, query }: FileManagerHighlightedNameProps) {
if (!query?.trim()) {
return <span className={cn('truncate', className)}>{name}</span>;
}
const lower = name.toLowerCase();
const lowerQuery = query.toLowerCase();
const segments: ReactNode[] = [];
let cursor = 0;
while (cursor < name.length) {
const matchIndex = lower.indexOf(lowerQuery, cursor);
if (matchIndex < 0) {
segments.push(<Fragment key={`t-${cursor}`}>{name.slice(cursor)}</Fragment>);
break;
}
if (matchIndex > cursor) {
segments.push(<Fragment key={`p-${cursor}`}>{name.slice(cursor, matchIndex)}</Fragment>);
}
segments.push(
<mark
className="bg-primary/20 text-foreground rounded-[2px]"
key={`m-${matchIndex}`}
>
{name.slice(matchIndex, matchIndex + lowerQuery.length)}
</mark>,
);
cursor = matchIndex + lowerQuery.length;
}
return <span className={cn('truncate', className)}>{segments}</span>;
}
@@ -0,0 +1,107 @@
import {
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileJson,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
FolderOpen,
type LucideIcon,
} from 'lucide-react';
export interface FileTypeIcon {
icon: LucideIcon;
tone: string;
}
interface GetFileTypeIconArgs {
isDir: boolean;
isOpen?: boolean;
name: string;
}
const EXT_MAP: Record<string, FileTypeIcon> = {
'7z': { icon: FileArchive, tone: 'text-amber-500' },
aac: { icon: FileAudio, tone: 'text-fuchsia-500' },
avi: { icon: FileVideo, tone: 'text-violet-500' },
bash: { icon: FileCode, tone: 'text-green-500' },
bz2: { icon: FileArchive, tone: 'text-amber-500' },
c: { icon: FileCode, tone: 'text-blue-400' },
cfg: { icon: FileText, tone: 'text-muted-foreground' },
conf: { icon: FileText, tone: 'text-muted-foreground' },
cpp: { icon: FileCode, tone: 'text-blue-400' },
cs: { icon: FileCode, tone: 'text-emerald-500' },
css: { icon: FileCode, tone: 'text-sky-500' },
csv: { icon: FileSpreadsheet, tone: 'text-emerald-500' },
doc: { icon: FileText, tone: 'text-blue-500' },
docx: { icon: FileText, tone: 'text-blue-500' },
env: { icon: FileText, tone: 'text-muted-foreground' },
flac: { icon: FileAudio, tone: 'text-fuchsia-500' },
flv: { icon: FileVideo, tone: 'text-violet-500' },
gif: { icon: FileImage, tone: 'text-purple-500' },
go: { icon: FileCode, tone: 'text-cyan-500' },
gz: { icon: FileArchive, tone: 'text-amber-500' },
h: { icon: FileCode, tone: 'text-blue-400' },
hpp: { icon: FileCode, tone: 'text-blue-400' },
htm: { icon: FileCode, tone: 'text-orange-500' },
html: { icon: FileCode, tone: 'text-orange-500' },
ico: { icon: FileImage, tone: 'text-purple-500' },
ini: { icon: FileText, tone: 'text-muted-foreground' },
java: { icon: FileCode, tone: 'text-orange-500' },
jpeg: { icon: FileImage, tone: 'text-purple-500' },
jpg: { icon: FileImage, tone: 'text-purple-500' },
js: { icon: FileCode, tone: 'text-yellow-500' },
json: { icon: FileJson, tone: 'text-yellow-500' },
jsx: { icon: FileCode, tone: 'text-blue-500' },
kt: { icon: FileCode, tone: 'text-purple-500' },
log: { icon: FileText, tone: 'text-muted-foreground' },
md: { icon: FileText, tone: 'text-slate-400' },
mdx: { icon: FileText, tone: 'text-slate-400' },
mkv: { icon: FileVideo, tone: 'text-violet-500' },
mov: { icon: FileVideo, tone: 'text-violet-500' },
mp3: { icon: FileAudio, tone: 'text-fuchsia-500' },
mp4: { icon: FileVideo, tone: 'text-violet-500' },
ogg: { icon: FileAudio, tone: 'text-fuchsia-500' },
pdf: { icon: FileType, tone: 'text-red-500' },
php: { icon: FileCode, tone: 'text-indigo-500' },
png: { icon: FileImage, tone: 'text-purple-500' },
ppt: { icon: FileText, tone: 'text-orange-600' },
pptx: { icon: FileText, tone: 'text-orange-600' },
py: { icon: FileCode, tone: 'text-yellow-500' },
rar: { icon: FileArchive, tone: 'text-amber-500' },
rb: { icon: FileCode, tone: 'text-red-500' },
rs: { icon: FileCode, tone: 'text-orange-500' },
sh: { icon: FileCode, tone: 'text-green-500' },
sql: { icon: FileCode, tone: 'text-cyan-500' },
svg: { icon: FileImage, tone: 'text-pink-500' },
swift: { icon: FileCode, tone: 'text-orange-500' },
tar: { icon: FileArchive, tone: 'text-amber-500' },
toml: { icon: FileJson, tone: 'text-rose-500' },
ts: { icon: FileCode, tone: 'text-blue-500' },
tsx: { icon: FileCode, tone: 'text-blue-500' },
txt: { icon: FileText, tone: 'text-muted-foreground' },
wav: { icon: FileAudio, tone: 'text-fuchsia-500' },
webm: { icon: FileVideo, tone: 'text-violet-500' },
webp: { icon: FileImage, tone: 'text-purple-500' },
xls: { icon: FileSpreadsheet, tone: 'text-emerald-500' },
xlsx: { icon: FileSpreadsheet, tone: 'text-emerald-500' },
xml: { icon: FileCode, tone: 'text-orange-400' },
yaml: { icon: FileJson, tone: 'text-rose-500' },
yml: { icon: FileJson, tone: 'text-rose-500' },
zip: { icon: FileArchive, tone: 'text-amber-500' },
};
export const getFileTypeIcon = ({ isDir, isOpen, name }: GetFileTypeIconArgs): FileTypeIcon => {
if (isDir) {
return { icon: isOpen ? FolderOpen : Folder, tone: 'text-blue-400' };
}
const ext = name.split('.').pop()?.toLowerCase() ?? '';
return EXT_MAP[ext] ?? { icon: File, tone: 'text-muted-foreground' };
};
@@ -0,0 +1,536 @@
import { ChevronRight, Ellipsis } from 'lucide-react';
import {
type CSSProperties,
memo,
type FocusEvent as ReactFocusEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type PointerEvent as ReactPointerEvent,
type SyntheticEvent,
useMemo,
useState,
} from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { FileManagerAction, FileManagerInternalNode, FileNode } from './file-manager-types';
import type { FileManagerNodeDndHandlers } from './use-file-manager-dnd';
import { FileManagerHighlightedName } from './file-manager-highlighted-name';
import { getFileTypeIcon } from './file-manager-icons';
import { formatModifiedRelative as defaultFormatModified, formatFileSize } from './file-manager-utils';
/**
* Marker on every interactive child of the row that should NOT bubble into a row
* click or double-click. Detected via `closest()` in the row's handlers — descendants
* don't need to call `event.stopPropagation()` themselves.
*/
const SKIP_ROW_CLICK_ATTR = 'data-fm-skip-row-click';
const skipRowClickProps = { [SKIP_ROW_CLICK_ATTR]: '' };
/**
* Layout/visibility/i18n props that are identical for every row in the tree.
* `FileManager` builds this object once with `useMemo` so memoized rows do not
* have to compare seven separate primitives on every parent re-render — a single
* reference check is enough.
*/
export interface FileManagerRowDisplay {
formatModified?: (modifiedAt: Date | string | undefined) => string;
gridTemplate: string;
hasActions: boolean;
isCheckboxVisible: boolean;
isModifiedVisible: boolean;
isSizeVisible: boolean;
searchQuery?: string;
}
/**
* Stable callback bundle shared by every row. All handlers are produced by
* hooks that go through the latest-ref pattern, so this object is built once
* and never invalidates the row memo.
*/
export interface FileManagerRowHandlers {
onClick: (event: ReactMouseEvent, path: string, subtreePaths?: readonly string[]) => void;
onFocusRow: (path: string) => void;
onOpen?: (file: FileNode) => void;
/**
* Optional override for the directory "open" gesture (double-click / Enter).
* When set, replaces the default expand/collapse — used by navigation-style
* file browsers (e.g. drilling into a remote folder by replacing the listing).
*/
onOpenDirectory?: (dir: FileNode) => void;
onToggleExpand: (path: string, wasExpanded: boolean) => void;
/**
* Polymorphic selection toggle: file rows pass just `path`, directory rows
* pass the precomputed subtree so the whole branch flips in one gesture.
*/
onToggleSelection: (path: string, subtreePaths?: readonly string[]) => void;
}
interface FileManagerRowProps {
actions: readonly FileManagerAction[];
activeRowPath: null | string;
/**
* Tri-state checkbox value for directory rows (`true`, `false`, `'indeterminate'`).
* `undefined` for file rows — files fall back to `isSelected`.
*/
dirCheckboxState?: 'indeterminate' | boolean;
/**
* Pre-computed list of every selectable path in the directory's subtree
* (the directory itself plus all descendants). `undefined` for files.
* Captured by the directory-checkbox click handler so a single gesture
* flips the entire branch.
*/
dirSubtreePaths?: readonly string[];
/** Per-tree shared layout / i18n bundle (one stable reference). */
display: FileManagerRowDisplay;
/** Drag/drop handlers for this row. `null` when intra-tree DnD is disabled. */
dnd: FileManagerNodeDndHandlers | null;
file: FileManagerInternalNode;
/** Per-tree shared callback bundle (one stable reference). */
handlers: FileManagerRowHandlers;
isExpanded: boolean;
isSelected: boolean;
/** 1-based position of the row inside its parent's child list (for `aria-posinset`). */
posInSet: number;
/** Total number of siblings the row is part of (for `aria-setsize`). */
setSize: number;
}
/**
* Returns `true` when the click originated from an element opted-out of row activation.
*
* `Element` (not `HTMLElement`) is the correct guard: `<svg>` and its children
* (`<path>` etc.) are `SVGElement`s, which do NOT extend `HTMLElement` even
* though they share the `Element.closest()` API. Using `HTMLElement` here would
* make a click on the actual painted pixels of an icon (chevron, action button
* icon, …) bypass the skip-marker check and re-trigger row selection.
*/
const isClickInsideSkipZone = (target: EventTarget | null): boolean =>
target instanceof Element && !!target.closest(`[${SKIP_ROW_CLICK_ATTR}]`);
/**
* Returns `true` when the synthetic event originated from a portal-mounted
* descendant of this row in the React tree — e.g. a dropdown / context-menu
* item rendered to `document.body` by Radix.
*
* React synthetic events bubble through the **component tree**, not the DOM
* tree, so a click on a portaled `<DropdownMenuItem>` still bubbles up to
* the row's `onClick` even though its DOM lives outside the row. The
* `data-fm-skip-row-click` opt-out doesn't help here either — `closest()`
* walks DOM ancestors, and the portal isn't a DOM child of the row.
*
* The fix is structural: if the event's actual DOM target is not contained
* within the row's DOM subtree (the row is `event.currentTarget`), the event
* came from a portal — ignore it for selection / focus / open gestures.
*/
const isEventFromOutsideRowDom = (event: SyntheticEvent): boolean => {
const { currentTarget, target } = event;
return currentTarget instanceof Node && target instanceof Node && !currentTarget.contains(target);
};
// Filter rules:
// - directory row → keep only actions with `appliesToDirs: true`
// - file row → keep only actions with `appliesToFiles !== false` (default true)
// So the legacy `appliesToDirs` semantics (omit → files-only, true → both) still hold,
// and `appliesToFiles: false` carves out the directory-only subset.
const buildVisibleActions = (
actions: readonly FileManagerAction[],
file: FileManagerInternalNode,
): FileManagerAction[] =>
actions.filter((action) => (file.isDir ? action.appliesToDirs === true : action.appliesToFiles !== false));
function FileManagerRowImpl({
actions,
activeRowPath,
dirCheckboxState,
dirSubtreePaths,
display,
dnd,
file,
handlers,
isExpanded,
isSelected,
posInSet,
setSize,
}: FileManagerRowProps) {
const {
formatModified = defaultFormatModified,
gridTemplate,
hasActions,
isCheckboxVisible,
isModifiedVisible,
isSizeVisible,
searchQuery,
} = display;
const { onClick, onFocusRow, onOpen, onOpenDirectory, onToggleExpand, onToggleSelection } = handlers;
const { icon: Icon, tone } = useMemo(
() =>
file.groupIcon
? { icon: file.groupIcon, tone: 'text-blue-400' }
: getFileTypeIcon({ isDir: file.isDir, isOpen: isExpanded, name: file.name }),
[file.groupIcon, file.isDir, file.name, isExpanded],
);
const visibleActions = useMemo(() => buildVisibleActions(actions, file), [actions, file]);
const handleRowClick = (event: ReactMouseEvent) => {
// Drop events bubbling up from portaled menu content (Radix dropdown /
// context menu items rendered to document.body) — they reach this
// handler through React's component-tree bubbling, not DOM bubbling,
// and would otherwise reset / mutate the multi-selection on every
// action invocation.
if (isEventFromOutsideRowDom(event)) {
return;
}
if (isClickInsideSkipZone(event.target)) {
return;
}
// Hand the precomputed subtree paths to the selection hook for directory
// rows: a plain or `Cmd`/`Ctrl`+click on a folder then operates on the
// entire branch — including descendants of a collapsed folder — instead
// of just the folder's own path.
onClick(event, file.path, file.isDir ? dirSubtreePaths : undefined);
};
// Double-click is the row's "open" gesture. Directories default to expand /
// collapse (decoupling expansion from the single click keeps `Shift`/`Cmd`+click
// pure selection gestures and matches Finder/Explorer); navigation-style
// browsers can override this by passing `onOpenDirectory`, which gets called
// instead — typical for drilling into a remote container directory by
// replacing the listing rather than expanding inline. Files always forward
// to `onOpen` — typically wired to download / preview / open-in-tab. The
// chevron icon on the row's left edge always toggles expand/collapse for
// directories, regardless of `onOpenDirectory`.
const handleRowDoubleClick = (event: ReactMouseEvent) => {
// Same React-tree-bubbling guard as `handleRowClick` — a double-click
// on a portaled menu item must not be treated as a row "open" gesture
// (which would, for files, kick off a download via `onOpen`).
if (isEventFromOutsideRowDom(event)) {
return;
}
if (isClickInsideSkipZone(event.target)) {
return;
}
if (file.isDir) {
event.preventDefault();
if (onOpenDirectory) {
onOpenDirectory(file);
} else {
onToggleExpand(file.path, isExpanded);
}
return;
}
if (onOpen) {
event.preventDefault();
onOpen(file);
}
};
const renderActionItem = (
Component: typeof ContextMenuItem | typeof DropdownMenuItem,
action: FileManagerAction,
) => {
const ActionIcon = action.icon;
const itemClassName = cn(
action.variant === 'destructive' && 'text-destructive focus:bg-destructive/10 focus:text-destructive',
);
if (action.getHref) {
return (
<Component
asChild
className={itemClassName}
key={action.id}
>
<a
download={action.getHrefDownloadAttr?.(file) ?? true}
href={action.getHref(file)}
>
{ActionIcon ? <ActionIcon className="size-4" /> : null}
{action.label}
</a>
</Component>
);
}
return (
<Component
className={itemClassName}
key={action.id}
onSelect={() => action.onSelect(file)}
>
{ActionIcon ? <ActionIcon className="size-4" /> : null}
{action.label}
</Component>
);
};
const renderActionItems = (menuKind: 'context' | 'dropdown'): ReactNode[] => {
const MenuItem = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem;
const MenuSeparator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator;
const items: ReactNode[] = [];
for (const action of visibleActions) {
if (action.separatorBefore && items.length > 0) {
items.push(<MenuSeparator key={`separator-${action.id}`} />);
}
items.push(renderActionItem(MenuItem, action));
}
return items;
};
const dropdownItems = hasActions ? renderActionItems('dropdown') : [];
const contextItems = renderActionItems('context');
const hasOwnContextMenu = contextItems.length > 0;
const isActiveRow = activeRowPath === file.path;
// Mirror the hover highlight while a row-owned menu (right-click context
// menu or actions dropdown) is open so the user always knows which row
// the menu belongs to — pointer can drift off the row into the portaled
// menu content, which would otherwise drop the `:hover` state and leave
// the row visually indistinguishable from its neighbors.
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false);
const isMenuOpen = isContextMenuOpen || isDropdownMenuOpen;
const rowStyle = {
'--fm-depth': file.depth,
gridTemplateColumns: gridTemplate,
} as CSSProperties & Record<'--fm-depth', number>;
// Bind handlers may be present even when intra-tree move is off (external
// file-drop only) — in that case `dnd.canDrag` is false and the row should
// not advertise itself as grabbable.
const isDraggable = !!dnd && dnd.canDrag && !file.isGroupRoot;
const isDropTarget = dnd?.isDropTarget ?? false;
const isBeingDragged = dnd?.isBeingDragged ?? false;
const row = (
<div
aria-expanded={file.isDir ? isExpanded : undefined}
aria-level={file.depth + 1}
aria-posinset={posInSet}
aria-selected={isSelected}
aria-setsize={setSize}
className={cn(
'group hover:bg-muted grid cursor-pointer items-center gap-3 px-3 py-1.5 transition-colors outline-none',
'focus-visible:bg-muted/70 focus-visible:ring-ring focus-visible:ring-1',
// `select-none` keeps double-click reserved for expand/collapse
// — without it the browser would highlight the row's text on dblclick.
'select-none',
(isSelected || isMenuOpen) && 'bg-muted',
isDropTarget && 'bg-primary/10 ring-primary/40 ring-1 ring-inset',
// Ghost every row that's part of the in-flight drag (the grabbed row
// plus any other selected rows being moved together) so the user sees
// the entire batch on the move, not just the row whose drag image the
// browser is rendering. Combine reduced opacity + dashed outline so
// the effect is unmistakable even when the row is already selected
// (`bg-muted` would otherwise mute the opacity contrast).
isBeingDragged && 'border-muted-foreground/40 border border-dashed opacity-40',
// Counter-act the 1px border above to keep the row from shifting layout
// when the dashed border kicks in.
!isBeingDragged && 'border border-transparent',
)}
data-path={file.path}
draggable={isDraggable}
onClick={handleRowClick}
// Stop the contextmenu event from bubbling to the FileManager's
// empty-area context menu when the row has its own. `composeEventHandlers`
// (used by Radix's `asChild` Slot) only stops on `defaultPrevented`,
// not `propagationStopped`, so the row's own ContextMenuTrigger
// still fires after our handler — both behaviors compose cleanly.
// For rows without their own items we leave the event alone so it
// falls through to the outer empty-area menu (a sensible fallback).
onContextMenu={
hasOwnContextMenu ? (event: ReactMouseEvent<HTMLDivElement>) => event.stopPropagation() : undefined
}
onDoubleClick={handleRowDoubleClick}
onDragEnd={dnd?.onDragEnd}
onDragEnter={dnd?.onDragEnter}
onDragLeave={dnd?.onDragLeave}
onDragOver={dnd?.onDragOver}
onDragStart={dnd?.onDragStart}
onDrop={dnd?.onDrop}
// `focusin` (which React's `onFocus` listens to) bubbles through
// both DOM and React trees, so focusing a portaled menu item — or
// navigating between them with arrow keys — would otherwise fire
// the row's focus handler and silently change `activeRowPath` /
// the focus-derived "current dir". Same containment check as the
// click handlers gates this off.
onFocus={(event: ReactFocusEvent<HTMLDivElement>) => {
if (isEventFromOutsideRowDom(event)) {
return;
}
onFocusRow(file.path);
}}
// Touch counterpart of the `onContextMenu` guard above. Radix's
// `<ContextMenuTrigger>` opens the menu on touch devices via a
// long-press timer started in `onPointerDown` — the native
// `contextmenu` event the desktop guard relies on never fires.
// Without stopping React-tree bubble here, the OUTER empty-area
// trigger ALSO starts its own long-press timer, and the user
// ends up with two menus stacked on top of each other (inner
// row menu + empty-area menu) when long-pressing a row on
// mobile / tablets. Mouse `pointerdown` is left alone so click,
// selection and drag listeners higher up the tree keep working
// exactly as before — the desktop right-click path is already
// covered by `onContextMenu` above.
onPointerDown={
hasOwnContextMenu
? (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== 'mouse') {
event.stopPropagation();
}
}
: undefined
}
role="treeitem"
style={rowStyle}
tabIndex={isActiveRow ? 0 : -1}
>
{isCheckboxVisible ? (
<span
className="flex items-center"
{...skipRowClickProps}
>
<Checkbox
aria-label={`Select ${file.name}`}
// Directories surface a tri-state value derived from their
// descendants; files (and edge cases without a precomputed
// value) fall back to the row's own selection flag.
checked={file.isDir ? (dirCheckboxState ?? isSelected) : isSelected}
// For folders we hand the precomputed subtree to the
// selection hook so one gesture flips the entire branch
// (the directory itself + every descendant); files just
// toggle their own path.
onCheckedChange={() => onToggleSelection(file.path, file.isDir ? dirSubtreePaths : undefined)}
/>
</span>
) : (
<span
aria-hidden="true"
className="size-4"
/>
)}
<div className="relative flex min-w-0 items-center gap-1.5 self-stretch pl-[calc(var(--fm-depth)*16px)]">
{Array.from({ length: file.depth }, (_, i) => (
<span
aria-hidden="true"
className="bg-border pointer-events-none absolute -inset-y-1.75 w-px"
key={i}
style={{ left: `${i * 16 + 6}px` }}
/>
))}
{file.isDir ? (
<span
aria-hidden="true"
className="text-muted-foreground hover:bg-muted -mx-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors"
onClick={() => {
// Mirror the double-click / Enter semantics here so the
// chevron stays consistent with the row-level "open"
// gesture: navigation-style consumers (e.g. the remote
// container browser) drill into the folder instead of
// toggling expansion that has no children to show.
if (onOpenDirectory) {
onOpenDirectory(file);
} else {
onToggleExpand(file.path, isExpanded);
}
}}
{...skipRowClickProps}
>
<ChevronRight className={cn('size-3.5 transition-transform', isExpanded && 'rotate-90')} />
</span>
) : (
<span
aria-hidden="true"
className="-mx-0.5 size-4 shrink-0"
/>
)}
<Icon className={cn('size-4 shrink-0', tone)} />
<FileManagerHighlightedName
className={cn('text-sm', file.isGroupRoot && 'font-semibold')}
name={file.name}
query={searchQuery}
/>
</div>
{isSizeVisible && (
<span className="text-muted-foreground/80 shrink-0 text-xs tabular-nums">
{!file.isDir ? formatFileSize(file.size) : ''}
</span>
)}
{isModifiedVisible && (
<span className="text-muted-foreground/80 shrink-0 text-xs tabular-nums">
{formatModified(file.modifiedAt)}
</span>
)}
{hasActions && (
<span {...skipRowClickProps}>
{dropdownItems.length > 0 ? (
<DropdownMenu onOpenChange={setIsDropdownMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Row actions"
className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
size="icon-xs"
variant="ghost"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{dropdownItems}</DropdownMenuContent>
</DropdownMenu>
) : null}
</span>
)}
</div>
);
if (contextItems.length === 0) {
return row;
}
return (
<ContextMenu onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent>{contextItems}</ContextMenuContent>
</ContextMenu>
);
}
FileManagerRowImpl.displayName = 'FileManagerRow';
export const FileManagerRow = memo(FileManagerRowImpl);
@@ -0,0 +1,121 @@
import { type CSSProperties } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { FileManagerColumnsConfig } from './file-manager-types';
import { buildFileManagerGridTemplate } from './file-manager-utils';
interface FileManagerSkeletonRow {
depth: number;
isDir: boolean;
nameWidth: string;
sizeWidth: string;
}
const SKELETON_ROWS: readonly FileManagerSkeletonRow[] = [
{ depth: 0, isDir: true, nameWidth: 'w-28', sizeWidth: 'w-10' },
{ depth: 1, isDir: false, nameWidth: 'w-44', sizeWidth: 'w-12' },
{ depth: 1, isDir: false, nameWidth: 'w-36', sizeWidth: 'w-10' },
{ depth: 1, isDir: false, nameWidth: 'w-28', sizeWidth: 'w-14' },
{ depth: 0, isDir: true, nameWidth: 'w-36', sizeWidth: 'w-10' },
{ depth: 0, isDir: false, nameWidth: 'w-24', sizeWidth: 'w-14' },
{ depth: 0, isDir: false, nameWidth: 'w-32', sizeWidth: 'w-12' },
];
interface FileManagerSkeletonProps {
columns?: FileManagerColumnsConfig;
hasActions?: boolean;
isCheckboxVisible?: boolean;
}
export function FileManagerSkeleton({
columns,
hasActions = false,
isCheckboxVisible = false,
}: FileManagerSkeletonProps) {
const isSizeVisible = columns?.isSizeVisible ?? true;
const isModifiedVisible = columns?.isModifiedVisible ?? true;
const gridTemplate = buildFileManagerGridTemplate(isSizeVisible, isModifiedVisible, hasActions);
return (
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
<div
className="bg-muted/30 grid items-center gap-3 border-b px-3 py-2"
style={{ gridTemplateColumns: gridTemplate }}
>
{isCheckboxVisible ? (
<Skeleton className="size-4 shrink-0 rounded-sm" />
) : (
<span
aria-hidden="true"
className="size-4"
/>
)}
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 shrink-0 rounded-sm" />
<Skeleton className="h-3 w-12" />
</div>
{isSizeVisible && <Skeleton className="h-3 w-10" />}
{isModifiedVisible && <Skeleton className="h-3 w-16" />}
{hasActions && (
<span
aria-hidden="true"
className="size-7"
/>
)}
</div>
<div className="flex flex-col py-1">
{SKELETON_ROWS.map((row, index) => {
const rowStyle = {
'--fm-depth': row.depth,
gridTemplateColumns: gridTemplate,
} as CSSProperties & Record<'--fm-depth', number>;
return (
<div
className="grid items-center gap-3 border border-transparent px-3 py-1.5"
key={index}
style={rowStyle}
>
{isCheckboxVisible ? (
<Skeleton className="size-4 shrink-0 rounded-sm" />
) : (
<span
aria-hidden="true"
className="size-4"
/>
)}
<div className="flex min-w-0 items-center gap-1.5 pl-[calc(var(--fm-depth)*16px)]">
{row.isDir ? (
<Skeleton className="-mx-0.5 size-4 shrink-0 rounded-sm" />
) : (
<span
aria-hidden="true"
className="-mx-0.5 size-4 shrink-0"
/>
)}
<Skeleton className="size-4 shrink-0 rounded-sm" />
<Skeleton className={cn('h-4', row.nameWidth)} />
</div>
{isSizeVisible && (
<Skeleton className={cn('h-3 shrink-0', row.isDir ? 'w-0 opacity-0' : row.sizeWidth)} />
)}
{isModifiedVisible && <Skeleton className="h-3 w-16 shrink-0" />}
{hasActions && (
<span
aria-hidden="true"
className="size-7"
/>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { getStorageItem, setStorageItem } from '@/lib/local-storage';
import type { FileManagerSortState } from './file-manager-types';
/**
* Storage shape for the FileManager active sort. Mirrors the runtime
* `FileManagerSortState` (a `null` payload means "no sort, insertion order
* preserved"). The schema is the source of truth for the on-disk format —
* if the runtime type changes, this schema must change too so existing
* payloads either upgrade cleanly or get rejected (and discarded by the
* loader's safeParse fallback).
*/
const fileManagerSortingSchema = z
.object({
column: z.enum(['modified', 'name', 'size']),
direction: z.enum(['asc', 'desc']),
})
.nullable();
/**
* Loader for the FileManager sort descriptor. Both "nothing stored" and
* "stored as null" surface as `null` (no-sort) — the two cases are
* indistinguishable from the consumer's point of view.
*/
export const loadFileManagerSorting = (key: string): FileManagerSortState =>
getStorageItem(key, fileManagerSortingSchema);
export const saveFileManagerSorting = (key: string, sorting: FileManagerSortState): void =>
setStorageItem(key, sorting);
@@ -0,0 +1,98 @@
import type { FileManagerRowDisplay, FileManagerRowHandlers } from './file-manager-row';
import type { FileManagerAction, FileManagerInternalNode } from './file-manager-types';
import type { FileManagerNodeDndHandlers } from './use-file-manager-dnd';
import { FileManagerRow } from './file-manager-row';
interface FileManagerTreeNodeProps {
actions: readonly FileManagerAction[];
activeRowPath: null | string;
/** Returns drag/drop handlers for a node, or `null` when DnD is disabled. */
bindNodeDnd: (node: FileManagerInternalNode) => FileManagerNodeDndHandlers | null;
/** Pre-computed tri-state value per directory path; missing entries default to `false`. */
dirSelectionStates: ReadonlyMap<string, 'indeterminate' | boolean>;
/** Pre-computed subtree paths per directory path (the dir itself + descendants). */
dirSubtreePaths: ReadonlyMap<string, readonly string[]>;
/** Per-tree shared layout / i18n bundle. Forwarded as-is to every row. */
display: FileManagerRowDisplay;
expandedPaths: Set<string>;
/** Per-tree shared callback bundle. Forwarded as-is to every row. */
handlers: FileManagerRowHandlers;
node: FileManagerInternalNode;
/** 1-based position of the node inside its parent's child list (for `aria-posinset`). */
posInSet: number;
selectedPaths: ReadonlySet<string>;
/** Total number of siblings the node is part of (for `aria-setsize`). */
setSize: number;
}
/**
* Recursive tree node renderer. The component itself is not memoized — `FileManagerRow`
* is, which is where per-row reconciliation savings actually matter. Extracting the
* recursion into a component (instead of an inline `renderNode` function) keeps
* `FileManager` lean and gives React DevTools a real boundary to inspect.
*/
export function FileManagerTreeNode({
actions,
activeRowPath,
bindNodeDnd,
dirSelectionStates,
dirSubtreePaths,
display,
expandedPaths,
handlers,
node,
posInSet,
selectedPaths,
setSize,
}: FileManagerTreeNodeProps) {
const isExpanded = expandedPaths.has(node.path);
const isSelected = selectedPaths.has(node.path);
const renderChildren = node.isDir && isExpanded && node.children.length > 0;
const dnd = bindNodeDnd(node);
// Pre-resolve tri-state + subtree paths per row: keeping the lookup outside
// the memoized `FileManagerRow` lets each row receive primitive/stable props
// (`'indeterminate' | true | false | undefined` plus a `Map`-stored array
// reference) so a selection change only re-renders the rows whose computed
// state actually flipped.
const dirCheckboxState = node.isDir ? (dirSelectionStates.get(node.path) ?? false) : undefined;
const subtreePaths = node.isDir ? dirSubtreePaths.get(node.path) : undefined;
return (
<>
<FileManagerRow
actions={actions}
activeRowPath={activeRowPath}
dirCheckboxState={dirCheckboxState}
dirSubtreePaths={subtreePaths}
display={display}
dnd={dnd}
file={node}
handlers={handlers}
isExpanded={isExpanded}
isSelected={isSelected}
posInSet={posInSet}
setSize={setSize}
/>
{renderChildren &&
node.children.map((child, index) => (
<FileManagerTreeNode
actions={actions}
activeRowPath={activeRowPath}
bindNodeDnd={bindNodeDnd}
dirSelectionStates={dirSelectionStates}
dirSubtreePaths={dirSubtreePaths}
display={display}
expandedPaths={expandedPaths}
handlers={handlers}
key={child.id}
node={child}
posInSet={index + 1}
selectedPaths={selectedPaths}
setSize={node.children.length}
/>
))}
</>
);
}
@@ -0,0 +1,356 @@
import type { ComponentType, ReactNode } from 'react';
export interface FileManagerAction {
/**
* When true, the action is shown for directory rows. Defaults to false.
*
* Combine with {@link FileManagerAction.appliesToFiles} to scope the action:
* - `appliesToDirs: false, appliesToFiles: true` (default) → files only
* - `appliesToDirs: true, appliesToFiles: true` (default) → files + directories
* - `appliesToDirs: true, appliesToFiles: false` → directories only
* - `appliesToDirs: false, appliesToFiles: false` → never (filtered out)
*/
appliesToDirs?: boolean;
/**
* When true (default), the action is shown for file rows. Set to `false` for
* actions that only make sense on directory rows (e.g. "Upload files",
* "New folder") combined with `appliesToDirs: true`.
*/
appliesToFiles?: boolean;
/**
* If provided, the action is rendered as an `<a href>` link instead of a button.
* Useful for native browser downloads.
*/
getHref?: (file: FileNode) => string;
/**
* `download` attribute for the `<a>` (only meaningful with `getHref`). Browser
* uses it as a hint for the suggested filename — the actual content/extension
* is determined by the server.
*/
getHrefDownloadAttr?: (file: FileNode) => string;
icon?: ComponentType<{ className?: string }>;
/** Stable identifier — used as React `key`. */
id: string;
label: string;
/** Triggered on item activation. Ignored when `getHref` is set. */
onSelect: (file: FileNode) => void;
/** When true, separator is rendered before this action. */
separatorBefore?: boolean;
variant?: 'default' | 'destructive';
}
/**
* Single entry in the bulk-actions bar. By analogy with `FileManagerAction` for
* row dropdowns: the host owns the array, the bar just renders it.
*
* The `files` argument always arrives **deduped** — a directory and its descendants
* never both appear in the list, the parent wins (see `dedupeOverlappingPaths`).
*/
export interface FileManagerBulkAction {
/**
* Optional confirm dialog. When set, the bar gates `onSelect` behind it
* (used for destructive actions like delete).
*/
confirm?: FileManagerBulkActionConfirm;
icon?: ComponentType<{ className?: string }>;
/** Stable identifier — used as React `key`. */
id: string;
/**
* Greys-out the button when `true`. Receives the deduped selection so the
* action can disable itself contextually (e.g. when only directories are
* selected and it doesn't apply to them).
*/
isDisabled?: (files: FileNode[]) => boolean;
/**
* Removes the entry entirely when `true`. Same args as `isDisabled` —
* useful for actions that simply don't make sense for the current selection.
*/
isHidden?: (files: FileNode[]) => boolean;
label: string;
/** Invoked with the deduped selection. */
onSelect: (files: FileNode[]) => Promise<void> | void;
/**
* When `true`, the action is collapsed into the trailing overflow `…` menu
* instead of being rendered as a standalone button. Use for less-frequent
* actions to keep the bar uncluttered.
*/
overflow?: boolean;
/** Visual treatment of the inline button (no effect when `overflow: true`). */
variant?: 'default' | 'destructive';
}
/** Optional confirmation dialog config for a bulk action. */
export interface FileManagerBulkActionConfirm {
/** Submit-button label (default: action's `label`). */
confirmText?: string;
/** Body text. Receives the already-pluralized count label (e.g. "3 items"). */
description?: (countLabel: string) => string;
/** Title text. Receives the already-pluralized count label (e.g. "3 items"). */
title: (countLabel: string) => string;
}
/** Optional column toggles. Both columns default to visible. */
export interface FileManagerColumnsConfig {
/** Disable sorting on the "Modified" column header. Defaults to `true`. */
isModifiedSortable?: boolean;
isModifiedVisible?: boolean;
/** Disable sorting on the "Name" column header. Defaults to `true`. */
isNameSortable?: boolean;
/** Disable sorting on the "Size" column header. Defaults to `true`. */
isSizeSortable?: boolean;
isSizeVisible?: boolean;
}
/**
* Single entry in the right-click context menu surfaced over the empty area
* of the tree (i.e. anywhere outside a row). Distinct from {@link FileManagerAction}
* because it cannot reference a `FileNode` — the user clicked between rows,
* not on one. Typical use: "Upload files", "New folder" at the tree's root.
*
* The menu only renders when the host supplies a non-empty
* `emptyAreaActions` array. When a row has its own context items, the
* row-level menu wins (right-clicks on rows do not propagate to the empty-area
* menu — see `file-manager-row.tsx`).
*/
export interface FileManagerEmptyAreaAction {
icon?: ComponentType<{ className?: string }>;
/** Stable identifier — used as React `key`. */
id: string;
label: string;
onSelect: () => void;
/** When true, separator is rendered before this item. */
separatorBefore?: boolean;
variant?: 'default' | 'destructive';
}
export interface FileManagerInternalNode extends FileNode {
children: FileManagerInternalNode[];
depth: number;
groupIcon?: ComponentType<{ className?: string }>;
isGroupRoot?: boolean;
}
/**
* All user-facing strings. Pass to `FileManager` via the `labels` prop to localize.
* Every field is optional; defaults are English.
*/
export interface FileManagerLabels {
/** Cancel button in the bulk-actions bar. */
bulkCancel?: string;
/** Trigger label / aria-label for the trailing overflow `…` menu in the bulk bar. */
bulkMoreActions?: string;
/** aria-label for the header "expand/collapse all" toggle when the tree is fully expanded. */
collapseAllAriaLabel?: string;
columnModified?: string;
columnName?: string;
columnSize?: string;
/** aria-label for the header "expand/collapse all" toggle when at least one directory is collapsed. */
expandAllAriaLabel?: string;
/**
* Custom formatter for the "Modified" column. Receives the raw `modifiedAt`
* and must return a display string (or empty string for no value). When omitted,
* the default English relative formatter is used.
*/
formatModified?: (modifiedAt: Date | string | undefined) => string;
/**
* Custom formatter for the cumulative size summary shown in the bulk bar.
* Receives the byte total of every selected file (directories contribute
* the recursive sum of their descendants); when omitted, the default
* `formatFileSize` formatter is used. Return an empty string to suppress
* the size suffix entirely.
*/
formatSelectionSize?: (totalBytes: number) => string;
/** Pluralized "N item" / "N items" used for confirmation dialogs. */
pluralizeItems?: (count: number) => string;
/** aria-label for the header "select all" checkbox. */
selectAllAriaLabel?: string;
/** Label rendered in the bulk bar, e.g. "3 selected". */
selectedLabel?: (count: number) => string;
/**
* aria-label for a sortable column header button. Receives the column id and the
* current sort direction (`null` when the column is not currently sorted), and
* should describe the action the next click will perform. Defaults to a plain
* English description like `"Sort by name (ascending)"`.
*/
sortHeaderAriaLabel?: (column: FileManagerSortColumn, direction: FileManagerSortDirection | null) => string;
}
export interface FileManagerProps {
/** Single, ordered list of available row actions (built-in helpers live in `file-manager-actions`). */
actions?: readonly FileManagerAction[];
/**
* Ordered list of bulk actions rendered in the footer bar when at least one row
* is selected. When the list is empty / undefined, the bar is not shown at all
* unless `enableSelection` forces checkboxes (e.g. picker dialogs).
*
* Built-in helpers live in `file-manager-actions` (`bulkDeleteAction`,
* `bulkCopyPathsAction`, `bulkMoveAction`, `bulkCopyAction`).
*
* Each callback receives the **deduped** `FileNode[]`: if both a directory and
* one of its descendants were selected, only the directory is forwarded.
*/
bulkActions?: readonly FileManagerBulkAction[];
className?: string;
/** Per-column visibility flags. Defaults: `{ isSizeVisible: true, isModifiedVisible: true }`. */
columns?: FileManagerColumnsConfig;
/**
* Items rendered in the right-click context menu over the tree's empty
* area (anywhere outside a row). When omitted / empty, no empty-area
* menu is registered and the browser's native context menu is shown.
* See {@link FileManagerEmptyAreaAction} for the item shape.
*/
emptyAreaActions?: readonly FileManagerEmptyAreaAction[];
/** Empty state node (rendered when files.length === 0 and not loading). */
emptyState?: ReactNode;
/**
* Forces row checkboxes to be visible / hidden, independently of `bulkActions`.
* - `true` → checkboxes shown even without bulk actions (e.g. picker dialogs).
* - `false` → checkboxes hidden even when bulk actions are provided.
* - `undefined` (default) → checkboxes shown whenever `bulkActions` is non-empty.
*/
enableSelection?: boolean;
files: FileNode[];
/**
* Initial sort applied on first render when no value is loaded from
* `sortStorageKey`. Defaults to `null` (no sort, insertion order preserved).
*/
initialSorting?: FileManagerSortState;
/**
* Group directories before files at every tree level when a sort is active.
* Default: `true` — matches Finder/Explorer behaviour. Set to `false` to mix
* directories and files together by the chosen sort criterion.
*
* Has no effect while sorting is `null` (insertion order is preserved as-is).
*/
isFoldersFirst?: boolean;
isLoading?: boolean;
/** Localizable user-facing strings. */
labels?: FileManagerLabels;
/**
* Fires whenever the focused row changes (roving tabindex). The path is `null`
* until the user actually focuses a row via click or keyboard navigation —
* it does NOT auto-fall back to the first visible row, so callers can
* distinguish "user picked something" from "tree just rendered".
*
* Use it to implement context-aware actions (e.g. "Upload here" defaulting
* to the focused directory, or its parent for files). The supplied callback
* is read through a ref, so it does not need to be memoized.
*
* Emitted values may reference paths that no longer exist in `files` (e.g.
* the focused row was deleted by an external mutation); consumers should
* validate against their own data before using the path.
*/
onActiveRowChange?: (path: null | string) => void;
/**
* Optional handler for files dragged in from outside the page (e.g. the
* desktop / OS file explorer). When provided, dropping files onto a
* directory row — or onto any file row whose parent is a real directory —
* fires this callback with the dropped `File[]` and the resolved
* destination directory path. Drops on the empty area outside any row,
* on synthetic group-root headers and on top-level files fall through
* to whatever drag handler the host attaches around `FileManager`
* (e.g. a page-level DnD upload zone).
*
* The callback is independent of {@link onMoveItems}: external-file drop
* support can be enabled without intra-tree move support, and vice versa.
*/
onExternalFileDrop?: (files: File[], destinationDir: string) => Promise<void> | void;
/**
* Enables internal drag-and-drop: rows become draggable and directories accept drops.
* Invoked with the dragged item(s) and the destination directory path (`''` for root).
*
* `FileManager` does **not** mutate its own list — the caller is expected to refresh
* the data (or rely on subscriptions) so the new positions become visible.
*/
onMoveItems?: (sources: FileNode[], destinationDir: string) => Promise<void> | void;
/**
* Fired when the user "opens" a *file* row via double-click or `Enter`.
* Directories go through `onOpenDirectory` instead; when that is omitted,
* they fall back to the default Finder/Explorer-style expand/collapse.
*
* Use it to wire downloads, in-app previews, or open-in-tab behavior.
*/
onOpen?: (file: FileNode) => void;
/**
* Fired when the user "opens" a *directory* row via double-click, `Enter`,
* or a click on the row's chevron. When provided, **replaces** the default
* expand/collapse gesture — useful for navigation-style file browsers
* (e.g. drilling into a remote directory by replacing the listing instead
* of expanding inline). When omitted, directories keep the default
* expand/collapse behaviour for all three gestures.
*/
onOpenDirectory?: (dir: FileNode) => void;
/**
* Fires whenever the multi-selection changes. Use it from selection-only
* flows (e.g. resource pickers) where the parent owns its own confirm button
* and needs to know which items are currently checked.
*
* The supplied callback is read through a ref, so it does not need to be
* memoized — only meaningful selection changes will trigger it.
*
* The Set is owned by the manager and must be treated as read-only —
* mutating it will desync the manager's internal state.
*/
onSelectionChange?: (selectedPaths: ReadonlySet<string>) => void;
/**
* Fires whenever the active sort changes (header click). The supplied
* callback is read through a ref, so it does not need to be memoized.
*/
onSortingChange?: (sorting: FileManagerSortState) => void;
/** Synthetic top-level groups (e.g. Uploads / Container). When omitted, root is flat. */
rootGroups?: FileManagerRootGroup[];
/** Search query and matching empty state. Provide `query` to enable filtering. */
search?: FileManagerSearchConfig;
/**
* Controlled sort state. When set, the manager renders this value and
* delegates updates to `onSortingChange` (it does NOT update its own
* state and ignores `sortStorageKey`). Pass `null` to render with no sort.
*/
sorting?: FileManagerSortState;
/**
* When set, the active sort is persisted to `localStorage` under this key
* across page reloads. Ignored in controlled mode (when `sorting` is set).
* Pass a route-scoped key (e.g. `getTableStorageKey('/flows/files')`)
* to avoid collisions across pages.
*/
sortStorageKey?: string;
}
export interface FileManagerRootGroup {
defaultOpen?: boolean;
icon?: ComponentType<{ className?: string }>;
/** Stable identifier (also used for synthetic node id). */
id: string;
label: string;
/** Path prefix without trailing slash, e.g. `'uploads'` or `'container'`. */
pathPrefix: string;
}
/** Search-related props, grouped because `query` and `emptyState` always travel together. */
export interface FileManagerSearchConfig {
/** Empty state node rendered when `query` is set but yields no results. */
emptyState?: ReactNode;
/** When set, the tree is filtered to nodes matching the query (and their ancestors), and matches are highlighted. */
query?: string;
}
/** Sortable column identifier. Mirrors the visible column headers. */
export type FileManagerSortColumn = 'modified' | 'name' | 'size';
/** Direction of an active sort. */
export type FileManagerSortDirection = 'asc' | 'desc';
/** Active sort descriptor; `null` means "no sort, preserve insertion order". */
export type FileManagerSortState = null | {
column: FileManagerSortColumn;
direction: FileManagerSortDirection;
};
export interface FileNode {
id: string;
isDir: boolean;
modifiedAt?: Date | string;
name: string;
path: string;
size?: number;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,583 @@
import { ArrowDown, ArrowUp, ChevronRight } from 'lucide-react';
import {
type MouseEvent as ReactMouseEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { cn } from '@/lib/utils';
import type { FileManagerRowDisplay, FileManagerRowHandlers } from './file-manager-row';
import type {
FileManagerAction,
FileManagerBulkAction,
FileManagerEmptyAreaAction,
FileManagerProps,
FileManagerSortColumn,
FileManagerSortDirection,
FileNode,
} from './file-manager-types';
import { FileManagerBulkActionsBar } from './file-manager-bulk-actions-bar';
import { FileManagerSkeleton } from './file-manager-skeleton';
import { FileManagerTreeNode } from './file-manager-tree-node';
import {
collectDirectoryPaths,
collectVisibleFlat,
computeDirSelectionState,
computeSelectionTotalBytes,
dedupeOverlappingPaths,
findNodeByPath,
getCheckboxState,
} from './file-manager-utils';
import { useFileManagerData } from './use-file-manager-data';
import { useFileManagerDnd } from './use-file-manager-dnd';
import { useFileManagerExpansion } from './use-file-manager-expansion';
import { useFileManagerKeyboardNavigation } from './use-file-manager-keyboard';
import { useFileManagerSelection } from './use-file-manager-selection';
import { useFileManagerSorting } from './use-file-manager-sorting';
const EMPTY_ACTIONS: readonly FileManagerAction[] = Object.freeze([]);
const EMPTY_BULK_ACTIONS: readonly FileManagerBulkAction[] = Object.freeze([]);
const EMPTY_AREA_ACTIONS: readonly FileManagerEmptyAreaAction[] = Object.freeze([]);
const COLUMN_LABEL_FOR_ARIA: Record<FileManagerSortColumn, string> = {
modified: 'modified date',
name: 'name',
size: 'size',
};
const defaultSortHeaderAriaLabel = (
column: FileManagerSortColumn,
direction: FileManagerSortDirection | null,
): string => {
const label = COLUMN_LABEL_FOR_ARIA[column];
if (direction === 'asc') {
return `Sort by ${label} (descending)`;
}
if (direction === 'desc') {
return `Clear sorting on ${label}`;
}
return `Sort by ${label} (ascending)`;
};
/**
* Renders the right-click context menu items for the empty area of the tree.
* Kept as a plain helper so the same JSX can be reused inside the
* `<ContextMenuContent>` regardless of where it lives in the render tree.
*/
const renderEmptyAreaItems = (items: readonly FileManagerEmptyAreaAction[]): ReactNode[] => {
const nodes: ReactNode[] = [];
for (const action of items) {
if (action.separatorBefore && nodes.length > 0) {
nodes.push(<ContextMenuSeparator key={`separator-${action.id}`} />);
}
const ActionIcon = action.icon;
nodes.push(
<ContextMenuItem
className={cn(
action.variant === 'destructive' &&
'text-destructive focus:bg-destructive/10 focus:text-destructive',
)}
key={action.id}
onSelect={() => action.onSelect()}
>
{ActionIcon ? <ActionIcon className="size-4" /> : null}
{action.label}
</ContextMenuItem>,
);
}
return nodes;
};
export function FileManager({
actions,
bulkActions,
className,
columns,
emptyAreaActions,
emptyState,
enableSelection,
files,
initialSorting,
isFoldersFirst = true,
isLoading,
labels,
onActiveRowChange,
onExternalFileDrop,
onMoveItems,
onOpen,
onOpenDirectory,
onSelectionChange,
onSortingChange,
rootGroups,
search,
sorting: controlledSorting,
sortStorageKey,
}: FileManagerProps) {
const effectiveBulkActions = bulkActions ?? EMPTY_BULK_ACTIONS;
const hasBulkActions = effectiveBulkActions.length > 0;
const isCheckboxVisible = enableSelection ?? hasBulkActions;
const hasActions = !!actions?.length;
const isNameSortable = columns?.isNameSortable ?? true;
const isSizeSortable = columns?.isSizeSortable ?? true;
const isModifiedSortable = columns?.isModifiedSortable ?? true;
const { sorting, toggleSort } = useFileManagerSorting({
controlledSorting,
initialSorting,
onSortingChange,
sortStorageKey,
});
const {
allSelectablePaths,
dirSubtreePaths,
fullTree,
gridTemplate,
isFiltering,
isModifiedVisible,
isSizeVisible,
normalizedRootGroups,
trimmedSearch,
visibleTree,
} = useFileManagerData({
columns,
files,
hasActions,
isFoldersFirst,
rootGroups,
searchQuery: search?.query,
sorting,
});
const { expandedPaths, setExpansion, toggleExpand } = useFileManagerExpansion({
isFiltering,
normalizedRootGroups,
visibleTree,
});
// Universe of every directory path in the *full* tree (filter-independent).
// Powers the header "expand/collapse all" toggle: per the consumer-facing
// contract the gesture always operates on the entire tree, even when a
// search filter is active and only matching dirs are visible.
const allDirPaths = useMemo(() => collectDirectoryPaths(fullTree), [fullTree]);
const isAllExpanded = useMemo(() => {
if (allDirPaths.length === 0) {
return false;
}
for (const path of allDirPaths) {
if (!expandedPaths.has(path)) {
return false;
}
}
return true;
}, [allDirPaths, expandedPaths]);
const toggleExpandAll = useCallback(() => {
if (allDirPaths.length === 0) {
return;
}
setExpansion(allDirPaths, !isAllExpanded);
}, [allDirPaths, isAllExpanded, setExpansion]);
// Owned by the host (not the data hook) because it depends on `expandedPaths`,
// which in turn depends on `visibleTree` from the data hook — moving it inside
// would form a circular hook dependency.
const flatVisible = useMemo(() => collectVisibleFlat(visibleTree, expandedPaths), [visibleTree, expandedPaths]);
const {
clearSelection,
isAllSelected,
isSomeSelected,
onRowClick,
onToggleSelection,
selectedPaths,
toggleSelectAll,
} = useFileManagerSelection({ allSelectablePaths, dirSubtreePaths, flatVisible });
// Cumulative byte total of the deduped selection — fed into the bulk bar's
// size suffix ("3 selected · 14.2 MB"). Recomputed on selection / tree
// changes; cheap because the dedup keeps the visit list short and each
// subtree walk uses the same `findNodeByPath` traversal as the bar's other
// helpers. Skipped entirely when the bar is hidden so a no-checkbox tree
// never pays the cost.
const selectionTotalBytes = useMemo(() => {
if (!hasBulkActions || selectedPaths.size === 0) {
return 0;
}
return computeSelectionTotalBytes(fullTree, dedupeOverlappingPaths(selectedPaths));
}, [fullTree, hasBulkActions, selectedPaths]);
// Tri-state checkbox values per directory: derived from `selectedPaths` so a
// single state change updates every parent checkbox in lock-step. The map is
// re-built whenever the selection or the tree shape changes; rows pull only
// their own value out of it (see `FileManagerTreeNode`) which keeps the
// memoized `FileManagerRow` from re-rendering for unrelated paths.
//
// Counting logic lives in `computeDirSelectionState` — see its JSDoc for
// why the directory's own path is excluded from the count.
const dirSelectionStates = useMemo(() => {
const map = new Map<string, 'indeterminate' | boolean>();
for (const [path, paths] of dirSubtreePaths) {
map.set(path, computeDirSelectionState({ path, paths, selectedPaths }));
}
return map;
}, [dirSubtreePaths, selectedPaths]);
// Report selection changes upstream without forcing parents to memoize the
// callback — stash it in a ref so the effect only re-fires when the actual
// selection changes (not when a fresh function instance is passed in).
const onSelectionChangeRef = useLatestRef(onSelectionChange);
useEffect(() => {
onSelectionChangeRef.current?.(selectedPaths);
// `onSelectionChangeRef` is a stable ref; only `selectedPaths` should
// re-trigger the upstream emit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPaths]);
// Same latest-ref pattern for `onOpen` / `onOpenDirectory`: both bleed into
// the keyboard handler's deps and through `TreeNode` → `Row`, so a
// non-memoized parent callback would otherwise invalidate the memo on every
// row whenever the parent re-renders.
const onOpenRef = useLatestRef(onOpen);
const onOpenDirectoryRef = useLatestRef(onOpenDirectory);
const handleOpen = useCallback(
(file: FileNode) => {
onOpenRef.current?.(file);
},
[onOpenRef],
);
// We need the row + keyboard handler to know whether the consumer registered
// a custom directory-open callback so the default expand/collapse is bypassed
// only when an override actually exists. Wrapping in a stable callback keeps
// the row memo intact across parent re-renders, but we conditionally pass it
// (vs. an always-defined wrapper) by gating on the prop reference itself.
const handleOpenDirectory = useCallback(
(dir: FileNode) => {
onOpenDirectoryRef.current?.(dir);
},
[onOpenDirectoryRef],
);
const stableHandleOpenDirectory = onOpenDirectory ? handleOpenDirectory : undefined;
const [activeRowPath, setActiveRowPath] = useState<null | string>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const resolvedActiveRow = useMemo(() => {
if (activeRowPath && flatVisible.includes(activeRowPath)) {
return activeRowPath;
}
return flatVisible[0] ?? null;
}, [activeRowPath, flatVisible]);
// Mirror the `onSelectionChange` plumbing for the focused row: stash the
// callback in a ref so the effect only re-fires on actual `activeRowPath`
// changes, not on every parent re-render passing a fresh function. We
// emit the raw `activeRowPath` (not `resolvedActiveRow`) so consumers can
// distinguish "user picked something" from the auto-fallback to the first
// visible row that the roving tabindex uses internally.
const onActiveRowChangeRef = useLatestRef(onActiveRowChange);
useEffect(() => {
onActiveRowChangeRef.current?.(activeRowPath);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRowPath]);
const focusRow = useCallback((path: null | string) => {
if (!path) {
return;
}
const next = containerRef.current?.querySelector<HTMLElement>(
`[role="treeitem"][data-path="${CSS.escape(path)}"]`,
);
next?.focus();
}, []);
const handleRowFocus = useCallback((path: string) => {
setActiveRowPath(path);
}, []);
const handleKeyDown = useFileManagerKeyboardNavigation({
expandedPaths,
flatVisible,
focusRow,
isCheckboxVisible,
onClearSelection: clearSelection,
onOpen: handleOpen,
onOpenDirectory: stableHandleOpenDirectory,
onSelectAll: toggleSelectAll,
onSetActiveRow: setActiveRowPath,
onToggleExpand: toggleExpand,
onToggleSelection,
resolvedActiveRow,
visibleTree,
});
const dnd = useFileManagerDnd({
findNode: useCallback((path: string) => findNodeByPath(fullTree, path), [fullTree]),
onClearSelection: clearSelection,
onExternalFileDrop,
onMoveItems,
selectedPaths,
});
const handleContainerClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
clearSelection();
}
},
[clearSelection],
);
// Pull labels resolution and search-query trim up to render-top so they can
// feed into `display` below. Both must be computed unconditionally — the
// memoization that follows is guarded by `Object.is` on each dep, so a
// missing `labels` prop ({} on every render) only invalidates `formatModified`
// when the inner reference actually changes.
const effectiveLabels = labels ?? {};
const formatModified = effectiveLabels.formatModified;
const effectiveActions = actions ?? EMPTY_ACTIONS;
const searchQuery = trimmedSearch || undefined;
const sortHeaderAriaLabel = effectiveLabels.sortHeaderAriaLabel ?? defaultSortHeaderAriaLabel;
const renderSortableHeader = (column: FileManagerSortColumn, label: string, isSortable: boolean) => {
if (!isSortable) {
return <span aria-hidden="true">{label}</span>;
}
const direction: FileManagerSortDirection | null = sorting?.column === column ? sorting.direction : null;
return (
<Button
aria-label={sortHeaderAriaLabel(column, direction)}
aria-sort={direction === 'asc' ? 'ascending' : direction === 'desc' ? 'descending' : 'none'}
className="text-muted-foreground hover:text-primary -mx-2 flex h-auto justify-start gap-1.5 px-2 py-1 text-xs font-medium no-underline hover:no-underline"
onClick={() => toggleSort(column)}
variant="link"
>
{label}
{direction === 'asc' ? <ArrowDown /> : direction === 'desc' ? <ArrowUp /> : null}
</Button>
);
};
// Bundle all per-tree-shared layout / i18n props into a single object so the
// memoized `FileManagerRow` only does one reference check (instead of seven
// primitive comparisons) on every parent re-render.
const display = useMemo<FileManagerRowDisplay>(
() => ({
formatModified,
gridTemplate,
hasActions,
isCheckboxVisible,
isModifiedVisible,
isSizeVisible,
searchQuery,
}),
[formatModified, gridTemplate, hasActions, isCheckboxVisible, isModifiedVisible, isSizeVisible, searchQuery],
);
// Same trick for callbacks. Every dep here is already stabilized through
// a ref or empty deps inside its source hook, so `handlers` is constructed
// exactly once per `FileManager` instance and never invalidates row memo.
const handlers = useMemo<FileManagerRowHandlers>(
() => ({
onClick: onRowClick,
onFocusRow: handleRowFocus,
onOpen: handleOpen,
onOpenDirectory: stableHandleOpenDirectory,
onToggleExpand: toggleExpand,
onToggleSelection,
}),
[handleOpen, handleRowFocus, onRowClick, onToggleSelection, stableHandleOpenDirectory, toggleExpand],
);
if (isLoading) {
return (
<div className={className}>
<FileManagerSkeleton
columns={columns}
hasActions={hasActions}
isCheckboxVisible={isCheckboxVisible}
/>
</div>
);
}
if (files.length === 0) {
return <div className={className}>{emptyState}</div>;
}
if (isFiltering && visibleTree.length === 0) {
return <div className={className}>{search?.emptyState ?? emptyState}</div>;
}
const effectiveEmptyAreaActions = emptyAreaActions ?? EMPTY_AREA_ACTIONS;
const hasEmptyAreaActions = effectiveEmptyAreaActions.length > 0;
// The scrollable tree element. Wrapped in a Radix `<ContextMenu>` below
// when the host registered empty-area items — right-clicks on rows still
// open the row-level menu because rows stop the contextmenu event there
// (see `file-manager-row.tsx`), so the outer trigger only fires for
// clicks outside any row, which is the entire point.
const treeBody = (
<div
aria-label="File tree"
aria-multiselectable={isCheckboxVisible || undefined}
className={cn(
'flex flex-1 flex-col overflow-y-auto py-1 transition-colors',
// Highlight the whole tree only when the cursor is actually hovering
// the empty area outside any row — that's the only place a "drop to
// root" will be accepted. `border-radius: inherit` makes the inset
// ring follow the outer container's rounded corners (top corners are
// hidden behind the header, so only the bottom is visually affected).
dnd.container.isRootDropTarget &&
'bg-primary/10 ring-primary [border-radius:inherit] ring-1 ring-inset',
)}
onDragEnter={dnd.isEnabled ? dnd.container.onDragEnter : undefined}
onDragLeave={dnd.isEnabled ? dnd.container.onDragLeave : undefined}
onDragOver={dnd.isEnabled ? dnd.container.onDragOver : undefined}
onDrop={dnd.isEnabled ? dnd.container.onDrop : undefined}
role="tree"
>
{visibleTree.map((node, index) => (
<FileManagerTreeNode
actions={effectiveActions}
activeRowPath={resolvedActiveRow}
bindNodeDnd={dnd.bindNodeDnd}
dirSelectionStates={dirSelectionStates}
dirSubtreePaths={dirSubtreePaths}
display={display}
expandedPaths={expandedPaths}
handlers={handlers}
key={node.id}
node={node}
posInSet={index + 1}
selectedPaths={selectedPaths}
setSize={visibleTree.length}
/>
))}
</div>
);
const tree = hasEmptyAreaActions ? (
<ContextMenu>
<ContextMenuTrigger asChild>{treeBody}</ContextMenuTrigger>
<ContextMenuContent>{renderEmptyAreaItems(effectiveEmptyAreaActions)}</ContextMenuContent>
</ContextMenu>
) : (
treeBody
);
return (
<div
className={cn('flex flex-col overflow-hidden rounded-lg border', className)}
onClick={handleContainerClick}
onKeyDown={handleKeyDown}
ref={containerRef}
>
<div
className="text-muted-foreground grid items-center gap-3 border-b px-3 py-2 text-xs font-medium"
style={{ gridTemplateColumns: gridTemplate }}
>
{isCheckboxVisible ? (
<Checkbox
aria-label={effectiveLabels.selectAllAriaLabel ?? 'Select all'}
checked={getCheckboxState(isAllSelected, isSomeSelected)}
onCheckedChange={toggleSelectAll}
/>
) : (
<span
aria-hidden="true"
className="size-4"
/>
)}
<div className="flex min-w-0 items-center gap-1.5">
{allDirPaths.length > 0 ? (
<button
aria-expanded={isAllExpanded}
aria-label={
isAllExpanded
? (effectiveLabels.collapseAllAriaLabel ?? 'Collapse all')
: (effectiveLabels.expandAllAriaLabel ?? 'Expand all')
}
className="text-muted-foreground hover:bg-muted hover:text-primary focus-visible:ring-ring -mx-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors outline-none focus-visible:ring-1"
onClick={toggleExpandAll}
type="button"
>
<ChevronRight
className={cn('size-3.5 transition-transform', isAllExpanded && 'rotate-90')}
/>
</button>
) : (
<span
aria-hidden="true"
className="-mx-0.5 size-4 shrink-0"
/>
)}
{renderSortableHeader('name', effectiveLabels.columnName ?? 'Name', isNameSortable)}
</div>
{isSizeVisible && renderSortableHeader('size', effectiveLabels.columnSize ?? 'Size', isSizeSortable)}
{isModifiedVisible &&
renderSortableHeader('modified', effectiveLabels.columnModified ?? 'Modified', isModifiedSortable)}
{hasActions && (
<span
aria-hidden="true"
className="size-7"
/>
)}
</div>
{tree}
{hasBulkActions && (
<FileManagerBulkActionsBar
actions={effectiveBulkActions}
files={files}
labels={effectiveLabels}
onClearSelection={clearSelection}
selectedPaths={selectedPaths}
selectionTotalBytes={selectionTotalBytes}
/>
)}
</div>
);
}
@@ -0,0 +1,29 @@
export { FileManager } from './file-manager';
export {
bulkCopyAction,
bulkCopyPathsAction,
bulkDeleteAction,
bulkDownloadAction,
bulkMoveAction,
bulkPromoteAction,
copyPathAction,
deleteAction,
downloadAction,
} from './file-manager-actions';
export { getFileTypeIcon } from './file-manager-icons';
export type { FileTypeIcon } from './file-manager-icons';
export type {
FileManagerAction,
FileManagerBulkAction,
FileManagerBulkActionConfirm,
FileManagerColumnsConfig,
FileManagerEmptyAreaAction,
FileManagerLabels,
FileManagerProps,
FileManagerRootGroup,
FileManagerSortColumn,
FileManagerSortDirection,
FileManagerSortState,
FileNode,
} from './file-manager-types';
export { dedupeOverlappingPaths, formatModifiedAbsolute, formatModifiedRelative } from './file-manager-utils';
@@ -0,0 +1,167 @@
import { useMemo } from 'react';
import type {
FileManagerColumnsConfig,
FileManagerInternalNode,
FileManagerRootGroup,
FileManagerSortState,
FileNode,
} from './file-manager-types';
import {
buildFileManagerGridTemplate,
buildFileManagerTree,
collectAllNodePaths,
collectSubtreePaths,
filterFileManagerTree,
normalizeRootGroups,
sortFileManagerTree,
} from './file-manager-utils';
interface UseFileManagerDataArgs {
columns: FileManagerColumnsConfig | undefined;
files: FileNode[];
/**
* Whether the manager will render an actions column. Pulled from props by
* the caller so the column-template stays in lock-step with the row layout
* — `FileManager` and the row component must agree on the same `gridTemplate`.
*/
hasActions: boolean;
/**
* Whether to keep directories above files at every level when a sort is
* active. When `null`/no sort, this flag is a no-op.
*/
isFoldersFirst: boolean;
rootGroups: FileManagerRootGroup[] | undefined;
/** Raw `search.query` from props; trimming and emptiness checks live inside the hook. */
searchQuery: string | undefined;
/** Active sort descriptor; `null` preserves insertion order. */
sorting: FileManagerSortState;
}
interface UseFileManagerDataResult {
/**
* Universe of selectable paths (files + real directories) in the *visible*
* tree, in DFS order. Used as the source of truth for "select all" and for
* pruning stale selection entries when the file list changes.
*/
allSelectablePaths: string[];
/**
* Pre-computed list of every selectable path in each directory's subtree
* (the directory itself plus all descendants). Keyed by directory path.
* Powers both the tri-state checkbox value and the "toggle whole subtree"
* gesture without re-walking the tree on every render.
*/
dirSubtreePaths: Map<string, readonly string[]>;
/**
* Full tree built from the input files (groups expanded, parent placeholders
* filled). Required by drag-and-drop's `findNode` which needs every node,
* including those filtered out of the visible tree by the search query.
*/
fullTree: FileManagerInternalNode[];
/** CSS `grid-template-columns` value shared by the header and every row. */
gridTemplate: string;
/** True when a non-empty search query is active. Drives auto-expand-on-search. */
isFiltering: boolean;
isModifiedVisible: boolean;
isSizeVisible: boolean;
/**
* Normalized version of `rootGroups` (trailing slashes stripped). Returned
* even when the result is reference-equal to the input, so consumers can
* treat the value as the canonical group list.
*/
normalizedRootGroups: FileManagerRootGroup[] | undefined;
/** Trimmed search query (`''` when missing or whitespace-only). */
trimmedSearch: string;
/**
* The tree as the user actually sees it — same shape as `fullTree` when no
* filter is active, otherwise narrowed to nodes matching the search query
* (with their ancestors and descendants kept).
*/
visibleTree: FileManagerInternalNode[];
}
/**
* Pure, memoized data layer for `FileManager`. Owns the full chain
* `files → tree → visibleTree → derived collections (paths, subtree map, grid template)`
* so the host component is free to focus on wiring (selection / expansion /
* keyboard / DnD hooks) and rendering.
*
* `flatVisible` is intentionally NOT returned here — it depends on
* `expandedPaths`, which depends on `visibleTree`, which depends on this hook.
* Computing it inside would force a circular dependency between the data hook
* and the expansion hook. The host derives it with a single `useMemo`.
*/
export function useFileManagerData({
columns,
files,
hasActions,
isFoldersFirst,
rootGroups,
searchQuery,
sorting,
}: UseFileManagerDataArgs): UseFileManagerDataResult {
const isSizeVisible = columns?.isSizeVisible ?? true;
const isModifiedVisible = columns?.isModifiedVisible ?? true;
const normalizedRootGroups = useMemo(() => normalizeRootGroups(rootGroups), [rootGroups]);
const fullTree = useMemo(() => buildFileManagerTree(files, normalizedRootGroups), [files, normalizedRootGroups]);
const trimmedSearch = searchQuery?.trim() ?? '';
const isFiltering = trimmedSearch.length > 0;
// Sorting is applied AFTER the search filter so the filtered visible tree
// reads in the user's chosen order. `sortFileManagerTree` returns the
// input untouched when `sorting` is `null`, so the no-sort case stays a
// zero-cost reference pass-through (the parent `useMemo` below still
// bails out via reference equality).
const visibleTree = useMemo(() => {
const filtered = isFiltering ? filterFileManagerTree(fullTree, trimmedSearch) : fullTree;
return sortFileManagerTree(filtered, sorting, isFoldersFirst);
}, [fullTree, isFiltering, isFoldersFirst, sorting, trimmedSearch]);
const allSelectablePaths = useMemo(() => collectAllNodePaths(visibleTree), [visibleTree]);
// Pre-compute the subtree path list for every directory (and group root) once
// per tree shape: this powers both the tri-state checkbox value and the
// "toggle whole subtree" gesture without re-walking the tree on every render.
const dirSubtreePaths = useMemo(() => {
const map = new Map<string, readonly string[]>();
const visit = (nodes: FileManagerInternalNode[]): void => {
for (const node of nodes) {
if (node.isDir || node.isGroupRoot) {
map.set(node.path, collectSubtreePaths(node));
}
if (node.children.length > 0) {
visit(node.children);
}
}
};
visit(visibleTree);
return map;
}, [visibleTree]);
const gridTemplate = useMemo(
() => buildFileManagerGridTemplate(isSizeVisible, isModifiedVisible, hasActions),
[hasActions, isModifiedVisible, isSizeVisible],
);
return {
allSelectablePaths,
dirSubtreePaths,
fullTree,
gridTemplate,
isFiltering,
isModifiedVisible,
isSizeVisible,
normalizedRootGroups,
trimmedSearch,
visibleTree,
};
}
@@ -0,0 +1,727 @@
import { type DragEvent as ReactDragEvent, useCallback, useEffect, useRef, useState } from 'react';
import type { FileManagerInternalNode, FileNode } from './file-manager-types';
import { dedupeOverlappingPaths } from './file-manager-utils';
const FM_DND_MIME = 'application/x-fm-paths';
/** Sentinel destination — represents the root area outside any directory. */
export const FM_ROOT_DROP_SENTINEL = '__fm_root__';
export interface FileManagerContainerDndHandlers {
isRootDropTarget: boolean;
onDragEnter: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragOver: (event: ReactDragEvent<HTMLDivElement>) => void;
onDrop: (event: ReactDragEvent<HTMLDivElement>) => void;
}
export interface FileManagerNodeDndHandlers {
/**
* `true` when intra-tree move DnD is on (i.e. the row should set
* `draggable={true}` so the user can grab it). When only external-file
* drops are enabled, rows still bind drop handlers (so the highlight /
* counter logic works) but stay non-draggable, since there's no move
* destination contract for them.
*/
canDrag: boolean;
/**
* `true` when this row is part of the in-flight drag operation. Drives the
* "ghosted" appearance for every selected row when the user drags one of them,
* so it's obvious that the whole batch is being moved (not just the row whose
* native drag image the browser is showing).
*/
isBeingDragged: boolean;
isDropTarget: boolean;
onDragEnd: () => void;
onDragEnter: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragLeave: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragOver: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragStart: (event: ReactDragEvent<HTMLDivElement>) => void;
onDrop: (event: ReactDragEvent<HTMLDivElement>) => void;
}
interface UseFileManagerDndParams {
/** Stable "real" `FileNode` reference for a path — used to build the source list. */
findNode: (path: string) => FileManagerInternalNode | undefined;
/**
* Invoked when the user starts dragging a row that's NOT part of the current
* selection (Finder-style: "you're acting on something else, the selection is
* dropped") and after a successful drop (selection paths are now stale because
* the items live elsewhere). Optional — when omitted, selection isn't touched.
*/
onClearSelection?: () => void;
/**
* Optional handler for external (OS-side) file drops onto a directory row.
* When provided, dropping files from the desktop / file explorer onto any
* folder row (including a file row whose parent is a real folder, mirroring
* the intra-tree resolution) lands them in that folder instead of bubbling
* up to a page-level handler. When omitted, external drops fall through
* to the parent's drag handlers as before.
*
* The handler receives the dropped `File` list and the resolved destination
* directory (never the synthetic root sentinel — top-level rows that
* resolve to root still pass through, see {@link isRootPassthrough}).
*/
onExternalFileDrop?: (files: File[], destinationDir: string) => Promise<void> | void;
/** When undefined, DnD is fully disabled. */
onMoveItems?: (sources: FileNode[], destinationDir: string) => Promise<void> | void;
/**
* Currently-selected paths. When the user grabs a row that's part of this set,
* every selected item is dragged together; otherwise only the grabbed row.
* Synthetic group roots and overlapping descendants are filtered automatically.
*/
selectedPaths?: Set<string>;
}
interface UseFileManagerDndResult {
/** Returns drag/drop handlers bound to a specific tree node, or `null` when DnD is off. */
bindNodeDnd: (node: FileManagerInternalNode) => FileManagerNodeDndHandlers | null;
/**
* Drag/drop handlers for the outer tree container. Drops here are interpreted as
* "move to library root" and only fire when the cursor is over the empty area
* outside any row (rows always stop event propagation).
*/
container: FileManagerContainerDndHandlers;
/** When true, rows have to set `draggable={true}` themselves. */
isEnabled: boolean;
}
/** Returns the parent directory of a virtual path, or `''` for root. */
const getParentDir = (path: string): string => {
const idx = path.lastIndexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
};
/**
* Validates that every source can be moved into `destDir`:
* - never into itself,
* - never into its current parent (no-op),
* - never into one of its own descendants.
*/
const isValidMove = (sources: FileManagerInternalNode[], destDir: string): boolean => {
if (sources.length === 0) {
return false;
}
for (const src of sources) {
if (src.path === destDir) {
return false;
}
if (getParentDir(src.path) === destDir) {
return false;
}
if (src.isDir && (destDir === src.path || destDir.startsWith(`${src.path}/`))) {
return false;
}
}
return true;
};
const isFmDragEvent = (event: ReactDragEvent<HTMLDivElement>): boolean =>
event.dataTransfer.types?.includes(FM_DND_MIME) ?? false;
/**
* `true` when the drag carries OS-side files (i.e. an external drag from the
* desktop / file explorer rather than an intra-tree row drag). Used to route
* the row-level handlers into the upload code path instead of the move one.
*
* Browsers report these drags via the `'Files'` entry in
* `dataTransfer.types`; we deliberately don't read `dataTransfer.files` until
* `drop` because most browsers gate it for security on `dragenter` / `dragover`.
*/
const isExternalFileDragEvent = (event: ReactDragEvent<HTMLDivElement>): boolean =>
event.dataTransfer.types?.includes('Files') ?? false;
/**
* Top-level files (no parent directory) act as a pass-through to the container's
* root-drop logic: instead of treating the row as its own drop target, we let the
* drag event bubble so the container accepts it as a "move to library root".
*
* The user mental model: anything outside any folder is "the root" — that includes
* the empty padding *and* loose files sitting at the top level.
*/
const isRootPassthrough = (node: FileManagerInternalNode): boolean =>
!node.isDir && !node.isGroupRoot && getParentDir(node.path) === '';
/**
* Resolves the *effective* destination directory a drop on this row should land in:
*
* - real directory → the directory itself
* - file inside a real dir → the file's parent directory (so the entire folder
* feels like one drop zone, matching Finder/Explorer:
* anywhere you release inside a folder, the items
* land in that folder regardless of the row under
* the cursor)
*
* Returns `null` for synthetic group roots and for files whose parent isn't a
* real, non-group directory (e.g. files sitting directly under a group header) —
* those rows stay inert. Top-level loose files are excluded earlier via
* `isRootPassthrough`, so this function never returns `''`; an empty parent
* just means "no findable real parent" and resolves to `null`.
*/
const resolveDropTargetDir = (
node: FileManagerInternalNode,
findNode: (path: string) => FileManagerInternalNode | undefined,
): null | string => {
if (node.isGroupRoot) {
return null;
}
if (node.isDir) {
return node.path;
}
const parentDir = getParentDir(node.path);
if (parentDir === '') {
return null;
}
const parent = findNode(parentDir);
if (!parent || !parent.isDir || parent.isGroupRoot) {
return null;
}
return parentDir;
};
/**
* Encapsulates the drag-counter pattern + path-set tracking used by `FileManager` for
* intra-tree move-via-drag. Scoped to a single `FileManager` instance — all intra-instance
* drags share one set of refs, but two separate instances do not interfere.
*
* Row handlers stop event propagation so the container only sees drags over the empty
* (root) area. Counters live on `enter`/`leave` (per the W3C drag-counter pattern); drop
* targets are resolved on `over` only via `preventDefault`.
*
* File rows inside a folder transparently forward to the parent dir via
* `resolveDropTargetDir`, so dropping anywhere inside a folder (on its rows OR on
* the folder header) feels identical — matching Finder/Explorer. The parent's
* `dragenter` / a child file's `dragenter` write to the same counter, so moving
* the cursor between them keeps the highlight stable.
*/
export function useFileManagerDnd({
findNode,
onClearSelection,
onExternalFileDrop,
onMoveItems,
selectedPaths,
}: UseFileManagerDndParams): UseFileManagerDndResult {
// `isEnabled` keeps its legacy meaning ("internal move DnD is on") so
// existing consumers (e.g. row `draggable` flag, container root-drop
// wiring) keep working unchanged. External-file drops live on a separate
// flag and only contribute row-level handlers; they never make rows
// draggable or wire the container.
const isEnabled = !!onMoveItems;
const isExternalDropEnabled = !!onExternalFileDrop;
// Some node-level branches need to know whether the row should react to
// *any* drag event (move OR external) — this combined flag avoids
// repeating the OR at every entry.
const reactsToDrags = isEnabled || isExternalDropEnabled;
// Stash via ref so the dragstart handler doesn't re-create on every selection
// change (which would invalidate `bindNodeDnd` and re-render every row through
// the tree). Synced via effect to keep ESLint happy about render-time mutations.
const selectionRef = useRef(selectedPaths);
useEffect(() => {
selectionRef.current = selectedPaths;
}, [selectedPaths]);
const dragSourcesRef = useRef<FileManagerInternalNode[]>([]);
const containerCounterRef = useRef(0);
const nodeCounterRef = useRef(new Map<string, number>());
// Drop-target highlighting. `null` = no active drag, otherwise the path of the directory
// currently hovered, or `FM_ROOT_DROP_SENTINEL` when the user is over the empty root area.
const [dropTargetPath, setDropTargetPath] = useState<null | string>(null);
// Paths of every row currently being dragged. Used to ghost rows in the UI so the
// user sees that the whole selection is on the move, not just the grabbed row
// (whose drag image the browser already shows).
const [draggingPaths, setDraggingPaths] = useState<ReadonlySet<string>>(() => new Set());
const resetDragState = useCallback(() => {
dragSourcesRef.current = [];
containerCounterRef.current = 0;
nodeCounterRef.current.clear();
setDropTargetPath(null);
setDraggingPaths(new Set());
}, []);
const handleNodeDragStart = useCallback(
(node: FileManagerInternalNode, event: ReactDragEvent<HTMLDivElement>): void => {
if (!isEnabled || node.isGroupRoot) {
return;
}
const selection = selectionRef.current;
const isPartOfSelection = !!selection && selection.has(node.path);
// Build the source path list:
// - If the dragged row IS part of the selection → drag everything selected
// (deduped so a parent dir doesn't ship together with its descendants).
// - Otherwise → drag just this row, and clear the previous selection so
// the user isn't left with a stale highlight pointing at unrelated items.
const sourcePaths = isPartOfSelection && selection ? dedupeOverlappingPaths(selection) : [node.path];
if (!isPartOfSelection && selection && selection.size > 0) {
onClearSelection?.();
}
// Re-resolve through `findNode` so we drag the freshest data — the row's
// `node` prop may be stale if the tree was re-rendered mid-drag.
const sources: FileManagerInternalNode[] = [];
for (const path of sourcePaths) {
const fresh = findNode(path);
// Group roots are synthetic headers, never valid sources.
if (fresh && !fresh.isGroupRoot) {
sources.push(fresh);
}
}
if (sources.length === 0) {
return;
}
dragSourcesRef.current = sources;
event.dataTransfer.effectAllowed = 'move';
// Browsers require *some* payload for drag to start; the actual list lives
// in the ref. Newline-separate so external listeners can still parse it.
event.dataTransfer.setData(FM_DND_MIME, sources.map((source) => source.path).join('\n'));
// For multi-source drags swap the browser's default drag image (= the
// grabbed row only) with a compact "N items" badge — without it the
// user can't tell at a glance that the whole selection is on the move.
if (sources.length > 1) {
const badge = document.createElement('div');
badge.textContent = `${sources.length} items`;
badge.style.cssText = [
'position: fixed',
// Render off-screen so we never flash it to the user — `setDragImage`
// captures the visual immediately, the element itself is throwaway.
'top: -1000px',
'left: -1000px',
'padding: 6px 12px',
'background: hsl(var(--primary))',
'color: hsl(var(--primary-foreground))',
'border-radius: 6px',
'font: 500 13px system-ui, -apple-system, "Segoe UI", sans-serif',
'white-space: nowrap',
'pointer-events: none',
'box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18)',
].join(';');
document.body.appendChild(badge);
event.dataTransfer.setDragImage(badge, 12, 12);
// The browser snapshots the element synchronously into a bitmap, so
// it's safe to remove on the next frame.
requestAnimationFrame(() => badge.remove());
}
// Mark every dragged source so the rows can ghost themselves. setState
// here is safe — React commits the update *after* this handler returns,
// so the browser captures the drag image with the original (un-ghosted)
// styles before the dim style is applied.
setDraggingPaths(new Set(sources.map((source) => source.path)));
},
[findNode, isEnabled, onClearSelection],
);
const handleNodeDragEnter = useCallback(
(node: FileManagerInternalNode, event: ReactDragEvent<HTMLDivElement>): void => {
if (!reactsToDrags) {
return;
}
const isFm = isFmDragEvent(event);
// External file drags are only honoured when the host registered an
// `onExternalFileDrop` callback — otherwise the event must bubble
// out so a page-level handler can pick it up.
const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event);
if (!isFm && !isExternal) {
return;
}
// The "internal" branches need a registered move callback to do
// anything useful — gate accordingly so an external-only setup
// doesn't accidentally claim FM-mime drags it can't complete.
if (isFm && !isEnabled) {
return;
}
// Top-level files behave as part of the root drop area — let the event
// bubble so the container handler (for FM-mime drags) or the page-
// level external drop handler can pick it up.
if (isRootPassthrough(node)) {
return;
}
// Otherwise stop propagation so a drag over a row never bubbles into the
// container handler — otherwise the container would treat the row position
// as a drop into the empty (root) area and call the move API on release.
event.stopPropagation();
// Files inside a folder forward their drop logic to the parent dir
// (returned here by `resolveDropTargetDir`), so the user can release
// anywhere inside the folder and the items land in that folder —
// matching Finder/Explorer. The parent's row picks up the highlight
// automatically because every row compares `dropTargetPath` to its own
// `node.path`, and we set the parent's path here.
const targetDir = resolveDropTargetDir(node, findNode);
// External drags accept any directory; move drags additionally need
// a valid source/destination pairing (no self-into-self / parent etc.).
const isAcceptable = targetDir !== null && (isExternal || isValidMove(dragSourcesRef.current, targetDir));
if (!isAcceptable) {
// Cursor is now over a non-droppable row — make sure the previously
// shown root highlight (if any) gets cleared. Container `dragleave`
// gates clearing on `relatedTarget` to avoid flicker, so the row
// handler is responsible for this case.
setDropTargetPath((current) => (current === FM_ROOT_DROP_SENTINEL ? null : current));
return;
}
// Counter is keyed by `targetDir` so a folder row and any of its child
// file rows feed the SAME counter: moving the cursor between them keeps
// the highlight stable (W3C drag-counter pattern across siblings).
const counters = nodeCounterRef.current;
const next = (counters.get(targetDir) ?? 0) + 1;
counters.set(targetDir, next);
if (next === 1) {
setDropTargetPath(targetDir);
}
},
[findNode, isEnabled, isExternalDropEnabled, reactsToDrags],
);
const handleNodeDragLeave = useCallback(
(node: FileManagerInternalNode, event: ReactDragEvent<HTMLDivElement>): void => {
if (!reactsToDrags) {
return;
}
const isFm = isFmDragEvent(event);
const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event);
if (!isFm && !isExternal) {
return;
}
if (isFm && !isEnabled) {
return;
}
// Mirrors `handleNodeDragEnter`: pass-through rows must let `dragleave`
// bubble too, so the container's / page-level enter/leave counter
// stays balanced.
if (isRootPassthrough(node)) {
return;
}
event.stopPropagation();
// Decrement the same counter `dragenter` incremented — the resolved
// parent dir for file rows, the dir's own path for folder rows.
const targetDir = resolveDropTargetDir(node, findNode);
if (targetDir === null) {
return;
}
const counters = nodeCounterRef.current;
const current = counters.get(targetDir) ?? 0;
// Enter may have skipped incrementing (invalid move, pre-cleared root
// highlight branch) — nothing to undo in that case.
if (current === 0) {
return;
}
if (current <= 1) {
counters.delete(targetDir);
setDropTargetPath((value) => (value === targetDir ? null : value));
} else {
counters.set(targetDir, current - 1);
}
},
[findNode, isEnabled, isExternalDropEnabled, reactsToDrags],
);
const handleNodeDragOver = useCallback(
(node: FileManagerInternalNode, event: ReactDragEvent<HTMLDivElement>): void => {
if (!reactsToDrags) {
return;
}
const isFm = isFmDragEvent(event);
const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event);
if (!isFm && !isExternal) {
return;
}
if (isFm && !isEnabled) {
return;
}
// Pass-through rows defer drop-acceptance to the container (= root drop)
// for FM-mime drags, and to the page-level handler for external drags.
if (isRootPassthrough(node)) {
return;
}
// Otherwise stop propagation; only `preventDefault` (= "drop allowed") for
// valid directory targets. Without the stop, the container would
// `preventDefault` on top of us and accept any row position as a root drop.
event.stopPropagation();
const targetDir = resolveDropTargetDir(node, findNode);
if (targetDir === null) {
return;
}
if (isFm && !isValidMove(dragSourcesRef.current, targetDir)) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = isFm ? 'move' : 'copy';
},
[findNode, isEnabled, isExternalDropEnabled, reactsToDrags],
);
const handleNodeDrop = useCallback(
(node: FileManagerInternalNode, event: ReactDragEvent<HTMLDivElement>): void => {
if (!reactsToDrags) {
return;
}
const isFm = isFmDragEvent(event);
const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event);
if (!isFm && !isExternal) {
return;
}
if (isFm && !isEnabled) {
return;
}
// Pass-through rows let the container handle the FM-mime drop (= root move)
// or, for external drags, bubble out to the page-level handler.
if (isRootPassthrough(node)) {
return;
}
// Stop propagation so the drop never bubbles to the container — both
// for FM-mime drags (otherwise the row position would be treated as
// a root move) and for external drags (otherwise a page-level
// listener would also process the same files and double-upload).
event.stopPropagation();
const targetDir = resolveDropTargetDir(node, findNode);
if (targetDir === null) {
resetDragState();
return;
}
if (isFm) {
const sources = dragSourcesRef.current;
if (!isValidMove(sources, targetDir)) {
resetDragState();
return;
}
event.preventDefault();
resetDragState();
// Selection paths reference the OLD locations, which the move call is
// about to invalidate. Clear them so the bulk-actions bar / "select-all"
// checkbox don't show stale state.
onClearSelection?.();
void onMoveItems?.(sources, targetDir);
return;
}
// External-file branch — `dataTransfer.files` is finally readable on
// `drop` (most browsers gate access during enter/over for security).
const droppedFiles = Array.from(event.dataTransfer.files ?? []);
event.preventDefault();
resetDragState();
if (droppedFiles.length === 0) {
return;
}
void onExternalFileDrop?.(droppedFiles, targetDir);
},
[
findNode,
isEnabled,
isExternalDropEnabled,
onClearSelection,
onExternalFileDrop,
onMoveItems,
reactsToDrags,
resetDragState,
],
);
const bindNodeDnd = useCallback(
(node: FileManagerInternalNode): FileManagerNodeDndHandlers | null => {
// Bind handlers whenever ANY drag interaction is enabled (move OR
// external file drop). Rows still gate their own `draggable` flag
// on `isEnabled` via `file-manager-row.tsx`, so external-only
// setups don't accidentally make rows look grabbable.
if (!reactsToDrags) {
return null;
}
return {
canDrag: isEnabled,
isBeingDragged: draggingPaths.has(node.path),
isDropTarget: dropTargetPath === node.path,
onDragEnd: resetDragState,
onDragEnter: (event) => handleNodeDragEnter(node, event),
onDragLeave: (event) => handleNodeDragLeave(node, event),
onDragOver: (event) => handleNodeDragOver(node, event),
onDragStart: (event) => handleNodeDragStart(node, event),
onDrop: (event) => handleNodeDrop(node, event),
};
},
[
draggingPaths,
dropTargetPath,
handleNodeDragEnter,
handleNodeDragLeave,
handleNodeDragOver,
handleNodeDragStart,
handleNodeDrop,
isEnabled,
reactsToDrags,
resetDragState,
],
);
const handleContainerDragEnter = useCallback(
(event: ReactDragEvent<HTMLDivElement>): void => {
if (!isEnabled || !isFmDragEvent(event)) {
return;
}
containerCounterRef.current += 1;
if (!isValidMove(dragSourcesRef.current, '')) {
return;
}
// Highlight only when the user is *outside* every directory row — otherwise the
// node-level handler already owns the highlight and propagation was stopped there.
if (containerCounterRef.current === 1) {
setDropTargetPath((current) => current ?? FM_ROOT_DROP_SENTINEL);
}
},
[isEnabled],
);
const handleContainerDragLeave = useCallback(
(event: ReactDragEvent<HTMLDivElement>): void => {
if (!isEnabled || !isFmDragEvent(event)) {
return;
}
containerCounterRef.current = Math.max(containerCounterRef.current - 1, 0);
// Only clear the root highlight when the cursor truly left the container.
// `dragleave` also fires when the cursor enters a child element — clearing
// there would cause a flicker for pass-through rows (which immediately
// re-set the highlight via bubbled `dragenter`/`dragover`).
const relatedTarget = event.relatedTarget;
const cursorLeftContainer =
!(relatedTarget instanceof Node) || !event.currentTarget.contains(relatedTarget);
if (cursorLeftContainer) {
setDropTargetPath((current) => (current === FM_ROOT_DROP_SENTINEL ? null : current));
}
},
[isEnabled],
);
const handleContainerDragOver = useCallback(
(event: ReactDragEvent<HTMLDivElement>): void => {
if (!isEnabled || !isFmDragEvent(event)) {
return;
}
if (!isValidMove(dragSourcesRef.current, '')) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
// `dragover` fires continuously while the cursor is over the container's
// direct area (or a pass-through child). It is the moment-of-truth for the
// root highlight: relying on the counter pattern alone misses the case
// where the cursor is implicitly inside the container at `dragstart`
// (no `dragenter` ever fires on the container, so the counter stays at 0
// and the highlight never appears until the user leaves and re-enters).
setDropTargetPath((current) => current ?? FM_ROOT_DROP_SENTINEL);
},
[isEnabled],
);
const handleContainerDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>): void => {
if (!isEnabled || !isFmDragEvent(event)) {
return;
}
const sources = dragSourcesRef.current;
if (!isValidMove(sources, '')) {
resetDragState();
return;
}
event.preventDefault();
resetDragState();
// Selection paths are about to be invalidated by the move — clear them so
// the bulk bar doesn't display stale "N selected".
onClearSelection?.();
void onMoveItems?.(sources, '');
},
[isEnabled, onClearSelection, onMoveItems, resetDragState],
);
return {
bindNodeDnd,
container: {
isRootDropTarget: dropTargetPath === FM_ROOT_DROP_SENTINEL,
onDragEnter: handleContainerDragEnter,
onDragLeave: handleContainerDragLeave,
onDragOver: handleContainerDragOver,
onDrop: handleContainerDrop,
},
isEnabled,
};
}
@@ -0,0 +1,101 @@
import { useCallback, useMemo, useState } from 'react';
import type { FileManagerInternalNode, FileManagerRootGroup } from './file-manager-types';
import { collectDirectoryPaths } from './file-manager-utils';
interface UseFileManagerExpansion {
/** Effective set of expanded directory paths (auto-expansion + user overrides). */
expandedPaths: Set<string>;
/**
* Bulk override for "expand all" / "collapse all" gestures. Each path in
* `paths` gets its override set to `isExpanded`, replacing any previous
* per-path value. Passing an empty iterable is a no-op.
*/
setExpansion: (paths: Iterable<string>, isExpanded: boolean) => void;
/**
* Toggle expansion of a directory based on the state the caller is currently
* displaying. Passing `wasExpanded` is intentional — it captures the user's
* intent ("invert what I see") and lets the callback stay stable across renders,
* which keeps memoized rows from invalidating on every expansion change.
*/
toggleExpand: (path: string, wasExpanded: boolean) => void;
}
interface UseFileManagerExpansionArgs {
isFiltering: boolean;
/** Already-normalized root groups (pathPrefix without trailing slash). */
normalizedRootGroups: FileManagerRootGroup[] | undefined;
visibleTree: FileManagerInternalNode[];
}
/**
* Expansion state without `useEffect`: we never store the full set in state.
* Instead we keep a small map of *user-driven* overrides (path -> isExpanded) on top
* of two automatic sources:
* 1) `rootGroups[].defaultOpen` — initial group expansion,
* 2) on-search auto-expansion of every directory inside the filtered tree.
*/
export function useFileManagerExpansion({
isFiltering,
normalizedRootGroups,
visibleTree,
}: UseFileManagerExpansionArgs): UseFileManagerExpansion {
const [overrides, setOverrides] = useState<Map<string, boolean>>(() => new Map());
const expandedPaths = useMemo(() => {
const result = new Set<string>();
if (normalizedRootGroups?.length) {
for (const group of normalizedRootGroups) {
if (group.defaultOpen ?? true) {
result.add(group.pathPrefix);
}
}
}
if (isFiltering) {
for (const dirPath of collectDirectoryPaths(visibleTree)) {
result.add(dirPath);
}
}
for (const [path, isExpanded] of overrides) {
if (isExpanded) {
result.add(path);
} else {
result.delete(path);
}
}
return result;
}, [isFiltering, normalizedRootGroups, overrides, visibleTree]);
const toggleExpand = useCallback((path: string, wasExpanded: boolean) => {
setOverrides((prev) => {
const next = new Map(prev);
next.set(path, !wasExpanded);
return next;
});
}, []);
const setExpansion = useCallback((paths: Iterable<string>, isExpanded: boolean) => {
setOverrides((prev) => {
const next = new Map(prev);
let didChange = false;
for (const path of paths) {
if (next.get(path) !== isExpanded) {
next.set(path, isExpanded);
didChange = true;
}
}
return didChange ? next : prev;
});
}, []);
return { expandedPaths, setExpansion, toggleExpand };
}
@@ -0,0 +1,192 @@
import { type KeyboardEvent as ReactKeyboardEvent, useCallback } from 'react';
import type { FileManagerInternalNode, FileNode } from './file-manager-types';
import { clamp, collectSubtreePaths, findNodeByPath } from './file-manager-utils';
interface UseFileManagerKeyboardNavigationArgs {
expandedPaths: Set<string>;
/** Visible nodes in DFS order — universe of focusable rows. */
flatVisible: string[];
focusRow: (path: null | string) => void;
isCheckboxVisible: boolean;
onClearSelection: () => void;
/** Open handler for file rows (Enter). */
onOpen?: (file: FileNode) => void;
/**
* Open handler for directory rows (Enter). When set, replaces the default
* expand/collapse gesture for the focused folder — used by navigation-style
* browsers that drill into a remote directory instead of expanding inline.
*/
onOpenDirectory?: (dir: FileNode) => void;
onSelectAll: () => void;
onSetActiveRow: (path: null | string) => void;
onToggleExpand: (path: string, wasExpanded: boolean) => void;
/** Polymorphic selection toggle — same shape as the row checkbox handler. */
onToggleSelection: (path: string, subtreePaths?: readonly string[]) => void;
/** Currently focused row, or `null` if nothing is focused. */
resolvedActiveRow: null | string;
visibleTree: FileManagerInternalNode[];
}
/**
* Keyboard handler implementing the WAI-ARIA Tree pattern:
* Arrow Up/Down → move focus, Home/End → jump to ends, Arrow Left/Right → collapse/expand
* directories, Enter → fire `onOpenDirectory`/`onOpen` (file), defaulting to expand/collapse
* for directories, Space → toggle the row's checkbox (only when checkboxes are shown),
* Ctrl/Cmd+A → select all, Escape → clear selection.
*/
export function useFileManagerKeyboardNavigation({
expandedPaths,
flatVisible,
focusRow,
isCheckboxVisible,
onClearSelection,
onOpen,
onOpenDirectory,
onSelectAll,
onSetActiveRow,
onToggleExpand,
onToggleSelection,
resolvedActiveRow,
visibleTree,
}: UseFileManagerKeyboardNavigationArgs) {
return useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
onClearSelection();
return;
}
if (!resolvedActiveRow || flatVisible.length === 0) {
return;
}
const index = flatVisible.indexOf(resolvedActiveRow);
const moveTo = (nextIndex: number) => {
const nextPath = flatVisible[clamp(0, nextIndex, flatVisible.length - 1)] ?? null;
onSetActiveRow(nextPath);
focusRow(nextPath);
};
switch (event.key) {
case ' ':
case 'Spacebar': {
event.preventDefault();
if (!isCheckboxVisible) {
return;
}
// Mirror the click semantics of the row checkbox: pressing
// Space on a directory flips its whole subtree (the dir +
// every descendant), on a file it toggles just that path.
const node = findNodeByPath(visibleTree, resolvedActiveRow);
const subtreePaths =
node && (node.isDir || node.isGroupRoot) ? collectSubtreePaths(node) : undefined;
onToggleSelection(resolvedActiveRow, subtreePaths);
return;
}
case 'A':
case 'a':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
onSelectAll();
}
return;
case 'ArrowDown':
event.preventDefault();
moveTo(index + 1);
return;
case 'ArrowLeft':
case 'ArrowRight': {
const node = findNodeByPath(visibleTree, resolvedActiveRow);
if (!node?.isDir) {
return;
}
const isExpanded = expandedPaths.has(node.path);
const wantsExpand = event.key === 'ArrowRight';
if (wantsExpand !== isExpanded) {
event.preventDefault();
onToggleExpand(node.path, isExpanded);
}
return;
}
case 'ArrowUp':
event.preventDefault();
moveTo(index - 1);
return;
case 'End':
event.preventDefault();
moveTo(flatVisible.length - 1);
return;
case 'Enter': {
const node = findNodeByPath(visibleTree, resolvedActiveRow);
if (!node) {
return;
}
// Always swallow Enter on a focused row so an enclosing form
// doesn't accidentally submit when the user just wanted to
// open a file or toggle a folder.
event.preventDefault();
if (node.isDir) {
// Navigation-style consumers (e.g. remote container browser)
// override expand/collapse with a "drill in" handler.
if (onOpenDirectory) {
onOpenDirectory(node);
} else {
onToggleExpand(node.path, expandedPaths.has(node.path));
}
return;
}
onOpen?.(node);
return;
}
case 'Home':
event.preventDefault();
moveTo(0);
return;
default:
return;
}
},
[
expandedPaths,
flatVisible,
focusRow,
isCheckboxVisible,
onClearSelection,
onOpen,
onOpenDirectory,
onSelectAll,
onSetActiveRow,
onToggleExpand,
onToggleSelection,
resolvedActiveRow,
visibleTree,
],
);
}
@@ -0,0 +1,191 @@
import { type MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
computeRowClickSelection,
computeToggleSelectAll,
computeToggleSelection,
resolveSelectionModifier,
} from './file-manager-utils';
interface UseFileManagerSelection {
clearSelection: () => void;
/** True when every selectable file is currently selected. */
readonly isAllSelected: boolean;
/** True when at least one — but not all — files are selected. */
readonly isSomeSelected: boolean;
/**
* Click handler honoring single / toggle / range semantics.
* Range computed against the supplied `flatVisible` order.
*
* When `subtreePaths` is provided (always for directory rows — the directory's
* own path plus every descendant), the single / toggle modifiers operate on
* the whole branch instead of just the row's path. This means a plain click
* on a folder row replaces the selection with the entire subtree (matching
* what the folder's tri-state checkbox would do), even when the folder is
* collapsed and its descendants aren't currently rendered. Shift+click still
* defines a visible range — the anchor is reset to the folder itself.
*/
onRowClick: (event: ReactMouseEvent, path: string, subtreePaths?: readonly string[]) => void;
/**
* Polymorphic toggle used by row checkboxes and the Space keyboard shortcut.
*
* - File rows / no `subtreePaths` → flip just `path`.
* - Directory rows pass the precomputed subtree (the directory itself plus
* every descendant) → all-or-nothing toggle of the whole branch: if every
* path is already selected the branch is cleared, otherwise the missing
* ones are added.
*
* `path` is also treated as the anchor for subsequent shift-range clicks.
*/
onToggleSelection: (path: string, subtreePaths?: readonly string[]) => void;
selectedPaths: Set<string>;
/** Replace entire selection. */
setSelection: (paths: Set<string>) => void;
/** Toggle "select all": clears if everything was selected, otherwise picks every file. */
toggleSelectAll: () => void;
}
interface UseFileManagerSelectionArgs {
/** Universe of selectable paths (files + real directories) in the *visible* tree. */
allSelectablePaths: string[];
/**
* Subtree paths per directory in the *visible* tree (the dir itself plus
* descendants). Forwarded to the range-click reducer so directories caught
* inside a Shift-range expand to their full branch — keeping their
* tri-state checkbox fully checked even when they were collapsed at the
* moment of the gesture (no descendants in `flatVisible` to count).
*/
dirSubtreePaths: ReadonlyMap<string, readonly string[]>;
/** Visible nodes in DFS order — used for shift-range selection. */
flatVisible: string[];
}
/**
* Selection state without `useEffect`: `rawSelectedPaths` may contain stale entries
* after files change; we derive a pruned `selectedPaths` during render. The pruned
* Set keeps reference identity when nothing was stripped, so memoized consumers
* downstream don't re-render needlessly.
*
* All real selection logic lives in pure reducers inside `file-manager-utils`
* (`computeRowClickSelection`, `computeToggleSelection`, `computeToggleSelectAll`).
* The hook is a thin wrapper that owns the state Set, the anchor ref, and a few
* latest-ref pointers needed to keep the callbacks reference-stable across
* expand/collapse and tree-shape changes.
*/
export function useFileManagerSelection({
allSelectablePaths,
dirSubtreePaths,
flatVisible,
}: UseFileManagerSelectionArgs): UseFileManagerSelection {
const [rawSelectedPaths, setRawSelectedPaths] = useState<Set<string>>(() => new Set());
const lastClickedRef = useRef<null | string>(null);
// Stash the visible-order list and the universe of selectable paths in refs
// so the click and select-all callbacks can stay reference-stable across
// expand/collapse and tree-shape changes. Without this, `onRowClick` would
// be re-created on every expansion (its deps include `flatVisible`) and
// invalidate the `onClick` prop on every memoized row — kicking the entire
// tree into a re-render on the most common user gesture. Same pattern is
// already used in `use-file-manager-dnd` for `selectionRef`.
const flatVisibleRef = useRef(flatVisible);
const allSelectablePathsRef = useRef(allSelectablePaths);
const dirSubtreePathsRef = useRef(dirSubtreePaths);
useEffect(() => {
flatVisibleRef.current = flatVisible;
}, [flatVisible]);
useEffect(() => {
allSelectablePathsRef.current = allSelectablePaths;
}, [allSelectablePaths]);
useEffect(() => {
dirSubtreePathsRef.current = dirSubtreePaths;
}, [dirSubtreePaths]);
const allSelectablePathsSet = useMemo(() => new Set(allSelectablePaths), [allSelectablePaths]);
const selectedPaths = useMemo(() => {
if (rawSelectedPaths.size === 0) {
return rawSelectedPaths;
}
let allValid = true;
const valid = new Set<string>();
for (const path of rawSelectedPaths) {
if (allSelectablePathsSet.has(path)) {
valid.add(path);
} else {
allValid = false;
}
}
return allValid ? rawSelectedPaths : valid;
}, [allSelectablePathsSet, rawSelectedPaths]);
const onRowClick = useCallback((event: ReactMouseEvent, path: string, subtreePaths?: readonly string[]) => {
const modifier = resolveSelectionModifier(event);
setRawSelectedPaths((prev) => {
const { next, nextAnchor } = computeRowClickSelection({
anchor: lastClickedRef.current,
dirSubtreePaths: dirSubtreePathsRef.current,
flatVisible: flatVisibleRef.current,
modifier,
path,
prev,
subtreePaths,
});
// Storing into the ref from inside the updater is safe here: the
// updater is called with a fresh `prev` on each invocation but the
// anchor it produces depends only on `modifier`, `path`, the current
// ref values and the supplied `subtreePaths` — so even if React
// re-runs us in strict mode, every run computes the same `nextAnchor`.
lastClickedRef.current = nextAnchor;
return next;
});
}, []);
const onToggleSelection = useCallback((path: string, subtreePaths?: readonly string[]) => {
// Anchor follow-up shift-range clicks at the directory itself (or the
// file's own path), not at the deepest leaf — matches what users expect
// from Finder/Explorer.
lastClickedRef.current = path;
setRawSelectedPaths((prev) => computeToggleSelection({ path, prev, subtreePaths }));
}, []);
const isAllSelected = allSelectablePaths.length > 0 && selectedPaths.size === allSelectablePaths.length;
const isSomeSelected = selectedPaths.size > 0 && !isAllSelected;
// Functional setState reads the current selectable universe through the ref
// declared above, so the callback identity stays stable regardless of how
// many times the file list or selection changes.
const toggleSelectAll = useCallback(() => {
setRawSelectedPaths((prev) =>
computeToggleSelectAll({ allSelectablePaths: allSelectablePathsRef.current, prev }),
);
}, []);
const clearSelection = useCallback(() => {
setRawSelectedPaths((prev) => (prev.size === 0 ? prev : new Set()));
}, []);
const setSelection = useCallback((paths: Set<string>) => {
setRawSelectedPaths(paths);
}, []);
return {
clearSelection,
isAllSelected,
isSomeSelected,
onRowClick,
onToggleSelection,
selectedPaths,
setSelection,
toggleSelectAll,
};
}
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffectAfterMount } from '@/hooks/use-effect-after-mount';
import type { FileManagerSortColumn, FileManagerSortState } from './file-manager-types';
import { loadFileManagerSorting, saveFileManagerSorting } from './file-manager-storage';
interface UseFileManagerSortingArgs {
/**
* Controlled sort value. When provided, the hook does not own state and
* delegates updates to `onSortingChange`. Persistence (`sortStorageKey`)
* is also bypassed — the parent owns the lifecycle.
*/
controlledSorting?: FileManagerSortState;
/**
* Initial value used when no `sortStorageKey` payload is found. Ignored in
* controlled mode (when `controlledSorting` is not undefined).
*/
initialSorting?: FileManagerSortState;
/** Fires whenever the active sort changes (uncontrolled mode only). */
onSortingChange?: (sorting: FileManagerSortState) => void;
/**
* `localStorage` key for persisting the active sort between reloads.
* Ignored in controlled mode. Pass `undefined` to disable persistence.
*/
sortStorageKey?: string;
}
interface UseFileManagerSortingResult {
/** Current active sort. `null` means "no sort, insertion order preserved". */
sorting: FileManagerSortState;
/**
* Cycle the sort state for the given column following the
* `none → asc → desc → none` order — matches DataTable header behaviour.
* - First click on an unsorted column → `asc`.
* - Click on a column already sorted `asc` → flip to `desc`.
* - Click on a column already sorted `desc` → clear.
* - Click on a *different* column → switch to that column at `asc`.
*/
toggleSort: (column: FileManagerSortColumn) => void;
}
/**
* Owns the active sort for a `FileManager`. Supports three modes:
* 1. **Uncontrolled, ephemeral** — `sortStorageKey` and `controlledSorting`
* both omitted. State lives only for the component lifetime.
* 2. **Uncontrolled, persisted** — `sortStorageKey` is set. State is loaded
* from `localStorage` on mount and rewritten whenever the user changes it.
* 3. **Controlled** — `controlledSorting` is set. The hook reflects that
* value verbatim and fires `onSortingChange` on user interaction.
*/
export function useFileManagerSorting({
controlledSorting,
initialSorting = null,
onSortingChange,
sortStorageKey,
}: UseFileManagerSortingArgs): UseFileManagerSortingResult {
const isControlled = controlledSorting !== undefined;
// `localStorage` access is guarded inside `loadFileManagerSorting`, so the
// lazy initializer is safe in SSR-style environments too.
const [internalSorting, setInternalSorting] = useState<FileManagerSortState>(() => {
if (isControlled) {
return controlledSorting ?? null;
}
if (sortStorageKey) {
const stored = loadFileManagerSorting(sortStorageKey);
if (stored !== null) {
return stored;
}
}
return initialSorting;
});
const sorting = isControlled ? (controlledSorting ?? null) : internalSorting;
useEffectAfterMount(() => {
if (!isControlled && sortStorageKey) {
saveFileManagerSorting(sortStorageKey, internalSorting);
}
}, [internalSorting, sortStorageKey, isControlled]);
// Latest-ref trick keeps `toggleSort` stable across re-renders even when
// the parent passes a fresh `onSortingChange` callback or the resolved
// sort value changes between renders.
const onSortingChangeRef = useRef(onSortingChange);
const sortingRef = useRef(sorting);
useEffect(() => {
onSortingChangeRef.current = onSortingChange;
}, [onSortingChange]);
useEffect(() => {
sortingRef.current = sorting;
}, [sorting]);
const isControlledRef = useRef(isControlled);
useEffect(() => {
isControlledRef.current = isControlled;
}, [isControlled]);
const toggleSort = useCallback((column: FileManagerSortColumn) => {
const next = computeNextSort(sortingRef.current, column);
if (!isControlledRef.current) {
setInternalSorting(next);
}
onSortingChangeRef.current?.(next);
}, []);
return { sorting, toggleSort };
}
/** Pure reducer for the header three-state cycle. Exported for unit tests. */
export const computeNextSort = (current: FileManagerSortState, column: FileManagerSortColumn): FileManagerSortState => {
if (current?.column !== column) {
return { column, direction: 'asc' };
}
if (current.direction === 'asc') {
return { column, direction: 'desc' };
}
return null;
};
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { Button, type ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface HeaderButtonProps extends Omit<ButtonProps, 'children'> {
endIcon?: ReactNode;
icon: ReactNode;
label: ReactNode;
}
// Action button rendered inside a page header. Collapses to an icon-only square
// on viewports narrower than the `md` breakpoint (matches `useBreakpoint`'s
// `mobile` threshold of 768px) and expands to icon + label (and optional
// trailing icon, e.g. a dropdown chevron) on wider screens. `aria-label` is
// auto-derived from `label` when it's a plain string so the icon-only mobile
// state stays accessible without the caller having to remember it.
export function HeaderButton({
'aria-label': ariaLabel,
className,
endIcon,
icon,
label,
size = 'sm',
...props
}: HeaderButtonProps) {
const accessibleLabel = ariaLabel ?? (typeof label === 'string' ? label : undefined);
return (
<Button
aria-label={accessibleLabel}
className={cn('w-8 px-0 md:w-auto md:px-3', className)}
size={size}
{...props}
>
{icon}
<span className="hidden md:inline">{label}</span>
{endIcon ? <span className="hidden md:inline-flex">{endIcon}</span> : null}
</Button>
);
}
@@ -0,0 +1,2 @@
export { InlineEditInput } from './inline-edit-input';
export { useInlineEdit } from './use-inline-edit';
@@ -0,0 +1,113 @@
import { Check, Loader2, X } from 'lucide-react';
import { type KeyboardEvent, type Ref } from 'react';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { cn } from '@/lib/utils';
interface InlineEditInputProps {
/**
* Auto-focus the input on mount. Needed for table-cell call sites that
* switch into edit mode in-place: the parent flips a flag (e.g.
* `editingFlowId === flow.id`) and this component is freshly mounted,
* so focus must be requested explicitly.
*/
autoFocus?: boolean;
/** Disable input + Save button while a mutation is in flight. */
busy?: boolean;
/** Optional className passed through to the outer `<InputGroup>`. */
className?: string;
/** Initial value rendered inside the uncontrolled input. */
defaultValue?: string;
/** Ref to the underlying `<input>` element. Pair with `useInlineEdit().inputRef`. */
inputRef?: Ref<HTMLInputElement>;
/**
* Max length applied via the native HTML `maxLength` attribute. Defaults
* to a UX-safe `200` to prevent accidental paste-bombs that would break
* truncation in tables and breadcrumbs. Override per call site when a
* stricter or looser constraint applies; the browser silently stops
* typing past the limit without altering programmatically-set
* `defaultValue`, so this is a guard rather than a validation gate.
*/
maxLength?: number;
onCancel: () => void;
/**
* Save handler. Read the latest text from the bound `inputRef` — kept
* uncontrolled because most callers commit on Enter or click and have
* no need to reflect every keystroke into React state.
*/
onSave: () => void;
placeholder?: string;
}
/**
* Generic inline-edit input used inside table cells and detail-page
* breadcrumbs (rename flows, quick-create entries, in-place note edits).
*
* Pairs with {@link useInlineEdit} — the parent owns the open/close state
* and supplies a ref via that hook; this component owns the presentation
* (input + Save/Cancel addon buttons), keyboard semantics (`Enter` saves,
* `Escape` cancels), and the loading spinner during save.
*
* The input is uncontrolled (`defaultValue`) to match the pattern across
* the codebase: callers read the value at submit time from `inputRef.current`,
* not from React state, which avoids a re-render per keystroke for a value
* that's only relevant once.
*/
export function InlineEditInput({
autoFocus = false,
busy = false,
className,
defaultValue,
inputRef,
maxLength = 200,
onCancel,
onSave,
placeholder,
}: InlineEditInputProps) {
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
onSave();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
onCancel();
}
};
return (
<InputGroup className={cn('h-8', className)}>
<InputGroupInput
autoFocus={autoFocus}
className="text-foreground"
defaultValue={defaultValue}
maxLength={maxLength}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={inputRef}
/>
<InputGroupAddon
align="inline-end"
className="gap-0 pr-2"
>
<InputGroupButton
aria-label="Save"
disabled={busy}
onClick={onSave}
>
{busy ? <Loader2 className="animate-spin" /> : <Check />}
</InputGroupButton>
<InputGroupButton
aria-label="Cancel"
disabled={busy}
onClick={onCancel}
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
}
@@ -0,0 +1,136 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useInlineEdit } from './use-inline-edit';
describe('useInlineEdit', () => {
it('starts in non-editing state', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
expect(result.current.isEditing).toBe(false);
});
it('startEdit flips to editing, stopEdit flips back', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
act(() => result.current.startEdit());
expect(result.current.isEditing).toBe(true);
act(() => result.current.stopEdit());
expect(result.current.isEditing).toBe(false);
});
it('exits edit mode when resetKey changes', () => {
const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), {
initialProps: { resetKey: 'a' as null | string | undefined },
});
act(() => result.current.startEdit());
expect(result.current.isEditing).toBe(true);
rerender({ resetKey: 'b' });
expect(result.current.isEditing).toBe(false);
});
it('keeps edit mode across rerenders with the same resetKey', () => {
const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), {
initialProps: { resetKey: 'a' as null | string | undefined },
});
act(() => result.current.startEdit());
rerender({ resetKey: 'a' });
expect(result.current.isEditing).toBe(true);
});
it('treats `null` and `undefined` as distinct keys (state reset on transition)', () => {
const { rerender, result } = renderHook(({ resetKey }) => useInlineEdit({ resetKey }), {
initialProps: { resetKey: null as null | string | undefined },
});
act(() => result.current.startEdit());
// Transition null -> undefined trips the reset because they aren't
// strictly equal. Documenting this so callers know to keep the
// resetKey type stable.
rerender({ resetKey: undefined });
expect(result.current.isEditing).toBe(false);
});
it('returns a ref object whose .current starts at null', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
expect(result.current.inputRef.current).toBeNull();
});
it('handleDropdownCloseAutoFocus prevents default while editing', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
act(() => result.current.startEdit());
// `Event` is cancelable by default but `defaultPrevented` only flips
// once `preventDefault` is called — exactly what the hook does.
const event = new Event('autofocus', { cancelable: true });
result.current.handleDropdownCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(true);
});
it('handleDropdownCloseAutoFocus is a no-op when not editing', () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
const event = new Event('autofocus', { cancelable: true });
result.current.handleDropdownCloseAutoFocus(event);
expect(event.defaultPrevented).toBe(false);
});
it('focuses and selects the input on the next animation frame after startEdit', async () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
const input = document.createElement('input');
input.value = 'hello';
document.body.appendChild(input);
// Attach the ref manually — `useRef` exposes `.current` as writable
// even though TypeScript narrows it to readonly. Casting through
// an interface keeps the intent visible in the test.
(result.current.inputRef as unknown as { current: HTMLInputElement }).current = input;
act(() => result.current.startEdit());
// requestAnimationFrame in jsdom resolves in a microtask — wait for it.
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(input.value.length);
document.body.removeChild(input);
});
it('cancels the pending focus when isEditing flips off before the frame fires', async () => {
const { result } = renderHook(() => useInlineEdit({ resetKey: 'a' }));
const input = document.createElement('input');
document.body.appendChild(input);
(result.current.inputRef as unknown as { current: HTMLInputElement }).current = input;
const otherInput = document.createElement('input');
document.body.appendChild(otherInput);
otherInput.focus();
act(() => result.current.startEdit());
act(() => result.current.stopEdit());
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
// Focus must not have moved to the inline input — the effect cleanup
// cancelled the rAF before it ran.
expect(document.activeElement).toBe(otherInput);
document.body.removeChild(input);
document.body.removeChild(otherInput);
});
});
@@ -0,0 +1,112 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseInlineEditOptions {
/**
* When this value changes, the edit session is reset (closes any open
* input). Use it for the entity id that owns the editor — navigating
* between items should not carry a stale draft over.
*/
resetKey?: null | string | undefined;
}
interface UseInlineEditResult<TElement extends HTMLElement = HTMLInputElement> {
/**
* Spread onto a Radix `<DropdownMenuContent>` (or any component with the
* same `onCloseAutoFocus` semantics) when the dropdown contains a button
* that toggles the inline editor. Prevents Radix's default focus-restore
* from racing the editor's `requestAnimationFrame`-driven focus below.
*/
handleDropdownCloseAutoFocus: (event: Event) => void;
/** Ref to wire to the inline `<input>` element. */
inputRef: React.RefObject<null | TElement>;
isEditing: boolean;
/** Begin an inline edit — the input mounts and receives focus on next frame. */
startEdit: () => void;
/** End the edit session without committing. */
stopEdit: () => void;
}
/**
* Shared state machine for inline-edit surfaces (double-click to rename,
* quick-add, in-place note edits).
*
* Combines four micro-responsibilities that every editable surface needs:
* - `isEditing` boolean + start/stop helpers,
* - a ref for the inline input,
* - deferred focus + select-all on the next animation frame, so the focus
* lands *after* Radix's dropdown close-focus-restore completes (otherwise
* Radix wins the race and the input never receives focus),
* - an `onCloseAutoFocus` handler that opts out of Radix's restore while
* editing — used together with the deferred focus above.
*
* Pass the entity id as `resetKey` so navigation between items closes any
* stale editor automatically.
*/
export function useInlineEdit<TElement extends HTMLElement = HTMLInputElement>({
resetKey,
}: UseInlineEditOptions = {}): UseInlineEditResult<TElement> {
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<null | TElement>(null);
// Reset the edit session when `resetKey` changes. React docs call out
// "adjust state when a prop changes" as the canonical render-phase
// `setState` pattern — it's preferred over `useEffect` because the new
// state lands in the same commit (no flash of stale "still editing" UI
// on the new item) and it doesn't trigger the "cascading renders"
// lint complaint that an effect-based reset would.
const [lastResetKey, setLastResetKey] = useState(resetKey);
if (lastResetKey !== resetKey) {
setLastResetKey(resetKey);
if (isEditing) {
setIsEditing(false);
}
}
useEffect(() => {
if (!isEditing) {
return;
}
const id = requestAnimationFrame(() => {
const input = inputRef.current;
if (!input) {
return;
}
input.focus();
// `<input>` and `<textarea>` both expose `select()`, but typing
// `TElement extends HTMLElement` is wider than that — guard so
// a future caller with `HTMLDivElement` doesn't crash here.
if ('select' in input && typeof (input as { select: unknown }).select === 'function') {
(input as unknown as HTMLInputElement).select();
}
});
return () => cancelAnimationFrame(id);
}, [isEditing]);
const startEdit = useCallback(() => setIsEditing(true), []);
const stopEdit = useCallback(() => setIsEditing(false), []);
// Closure over `isEditing` directly. The callback identity flips on each
// edit-mode toggle, but that's fine: Radix's `<DropdownMenuContent>` is
// not memoized today and `onCloseAutoFocus` fires inside the same commit
// that closes the dropdown — a ref-based stable callback would risk
// reading a stale value through `useLatestRef`'s `useEffect` lag.
const handleDropdownCloseAutoFocus = useCallback(
(event: Event) => {
if (isEditing) {
event.preventDefault();
}
},
[isEditing],
);
return { handleDropdownCloseAutoFocus, inputRef, isEditing, startEdit, stopEdit };
}
@@ -0,0 +1,494 @@
import type { Editor } from '@tiptap/react';
import { Placeholder } from '@tiptap/extensions';
import { history } from '@tiptap/pm/history';
import { EditorState } from '@tiptap/pm/state';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {
Bold,
Code,
Code2,
Heading1,
Heading2,
Heading3,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Minus,
Quote,
Redo,
Strikethrough,
Undo,
} from 'lucide-react';
import { type Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { Markdown } from 'tiptap-markdown';
import { Separator } from '@/components/ui/separator';
import { Toggle } from '@/components/ui/toggle';
import { cn } from '@/lib/utils';
export interface MarkdownEditorHandle {
focus: () => void;
getEditor: () => Editor | null;
}
export interface MarkdownEditorProps {
autoFocus?: boolean;
className?: string;
contentClassName?: string;
disabled?: boolean;
onBlur?: () => void;
onChange: (value: string) => void;
placeholder?: string;
showToolbar?: boolean;
value: string;
}
// Replaces the editor's ProseMirror history plugin with a fresh instance,
// effectively clearing the undo/redo stack. We call this on initial mount
// (to discard the construction-time transactions from extensions like
// `trailingNode`) and after every external content sync (`setContent`
// itself is a transaction that lands in history). Crucially, we MUST NOT
// call this on every value change — user edits also propagate through
// `value`, and wiping then would break Ctrl+Z.
const resetUndoHistory = (editor: Editor): void => {
const { state, view } = editor;
const historyIndex = state.plugins.findIndex((plugin) => {
const pluginKey = plugin.spec.key as undefined | { key?: string };
return pluginKey?.key?.startsWith('history$') ?? false;
});
if (historyIndex < 0) {
return;
}
const plugins = [...state.plugins];
plugins[historyIndex] = history();
// We MUST create a fresh `EditorState` rather than calling
// `state.reconfigure({ plugins })`. PM's `history()` factory uses a
// module-level singleton `PluginKey`, so every invocation returns a
// plugin keyed `history$`. `reconfigure` sees the same key in the old
// and new plugin arrays, decides "same plugin", and KEEPS the old
// state — meaning the undo stack stays populated even after our swap.
// `EditorState.create` has no prior state to carry over and initializes
// every plugin from scratch, which is exactly what we want here.
const newState = EditorState.create({
doc: state.doc,
plugins,
schema: state.schema,
selection: state.selection,
storedMarks: state.storedMarks,
});
view.updateState(newState);
// `view.updateState` bypasses PM's dispatch pipeline, so tiptap's
// `transaction` subscription doesn't fire — the toolbar would keep
// showing the stale `canUndo: true` value. Dispatch an empty
// transaction to wake the React subscription. The transaction is
// marked as non-historable so the freshly-empty stack stays empty.
view.dispatch(newState.tr.setMeta('addToHistory', false));
};
interface MarkdownEditorToolbarProps {
disabled?: boolean;
editor: Editor;
}
function MarkdownEditor({
autoFocus,
className,
contentClassName,
disabled,
onBlur,
onChange,
placeholder = 'Write something…',
ref,
showToolbar = true,
value,
}: MarkdownEditorProps & { ref?: Ref<MarkdownEditorHandle> }) {
const onChangeRef = useRef(onChange);
const onBlurRef = useRef(onBlur);
// Tracks the last markdown the editor reported externally. We compare
// against this to suppress echo updates: tiptap-markdown can re-serialize
// content slightly differently than the input string (whitespace/list
// markers/hard breaks/etc.), and we don't want to flag those
// normalizations as user edits — that would falsely flip RHF's
// `isDirty` flag.
//
// The baseline is the editor's own serialized markdown, NOT the raw
// input `value`. Comparing user edits against the canonical
// (post-normalization) form is what makes the comparison correct.
const lastEmittedRef = useRef<string>(value);
// Tiptap dispatches transactions for the initial content during view
// construction. Those `onUpdate` calls are echoes of the initial
// parse, not real user edits — forwarding them to RHF would mark
// the form dirty on mount. We start forwarding edits only after
// `onCreate` has captured the canonical baseline.
const isInitializedRef = useRef(false);
// Tracks whether we have already cleared the undo stack on initial
// mount. After the first sync useEffect run we set this to true; on
// subsequent renders we only clear the stack when an external value
// arrived (see `shouldExternalSync` below).
const hasResetInitialHistoryRef = useRef(false);
useEffect(() => {
onChangeRef.current = onChange;
onBlurRef.current = onBlur;
}, [onChange, onBlur]);
const editor = useEditor({
content: value,
editable: !disabled,
extensions: [
StarterKit.configure({
codeBlock: { HTMLAttributes: { class: 'hljs' } },
}),
Placeholder.configure({
emptyEditorClass: 'is-editor-empty',
placeholder,
}),
Markdown.configure({
breaks: true,
html: false,
linkify: true,
tightLists: true,
transformCopiedText: true,
// Plain text pasted from the OS clipboard is left as-is.
// With `transformPastedText: true`, a leading "- " (or
// "1. ", "> ", etc.) would be parsed as markdown and
// turn the paste into a list/blockquote — almost never
// what the user wants for knowledge documents.
transformPastedText: false,
}),
],
immediatelyRender: false,
onBlur: () => onBlurRef.current?.(),
onCreate: ({ editor: instance }) => {
lastEmittedRef.current = instance.storage.markdown.getMarkdown();
isInitializedRef.current = true;
},
onUpdate: ({ editor: instance }) => {
if (!isInitializedRef.current) {
return;
}
const next = instance.storage.markdown.getMarkdown();
if (next === lastEmittedRef.current) {
return;
}
lastEmittedRef.current = next;
onChangeRef.current?.(next);
},
});
useImperativeHandle(
ref,
() => ({
focus: () => editor?.commands.focus(),
getEditor: () => editor,
}),
[editor],
);
// Keep external value in sync (e.g. on form reset). Avoid resetting if
// the editor already reflects the same markdown to keep cursor stable.
useEffect(() => {
if (!editor) {
return;
}
const current = editor.storage.markdown.getMarkdown();
const shouldExternalSync = current !== value;
if (shouldExternalSync) {
editor.commands.setContent(value, { emitUpdate: false });
}
// The editor's own serialized markdown is the canonical form —
// subsequent user edits will be compared against this
// representation, not the (possibly non-normalized) input
// `value`. Storing the raw `value` here would cause the first
// user keystroke to also re-emit the round-trip normalization.
lastEmittedRef.current = editor.storage.markdown.getMarkdown();
// Clear the undo stack:
// - on initial mount, to discard the construction-time
// transactions dispatched by extensions like `trailingNode`
// plus the initial `setContent` we just ran above;
// - on every external `setContent` (e.g. parent calls
// form.reset(serverDocument)), because that transaction is
// not a user edit either.
//
// We MUST NOT reset on every render: when the user types,
// `value` changes too, and resetting then would erase the
// user's own undo history.
if (!hasResetInitialHistoryRef.current || shouldExternalSync) {
hasResetInitialHistoryRef.current = true;
resetUndoHistory(editor);
}
}, [editor, value]);
useEffect(() => {
if (!editor) {
return;
}
editor.setEditable(!disabled);
}, [editor, disabled]);
useEffect(() => {
if (autoFocus && editor) {
editor.commands.focus('end');
}
// `autoFocus` is intentionally omitted from the deps — it's a
// mount-time prop, equivalent to the native `<input autoFocus>`
// attribute. We don't want a parent flipping `autoFocus` later
// to steal focus back into the editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
// While tiptap is initializing (`useEditor` returns `null` on the
// first render with `immediatelyRender: false`), render a placeholder
// with the same outer classes so the bounding box is already correct
// and the parent layout doesn't jump when the editor mounts.
if (!editor) {
return (
<div
aria-busy="true"
className={cn(
'border-input dark:bg-input/30 group/markdown-editor flex w-full flex-col overflow-hidden rounded-md border shadow-2xs outline-hidden transition-[color,box-shadow]',
disabled && 'pointer-events-none opacity-60',
className,
)}
data-slot="markdown-editor"
/>
);
}
return (
<div
className={cn(
'border-input dark:bg-input/30 group/markdown-editor flex w-full flex-col overflow-hidden rounded-md border shadow-2xs outline-hidden transition-[color,box-shadow]',
'focus-within:ring-ring focus-within:ring-1',
disabled && 'pointer-events-none opacity-60',
className,
)}
data-slot="markdown-editor"
>
{showToolbar ? (
<MarkdownEditorToolbar
disabled={disabled}
editor={editor}
/>
) : null}
<EditorContent
className={cn(
'prose prose-sm dark:prose-invert tiptap-content max-w-none min-w-0 flex-1 overflow-auto px-3 py-2',
'[&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none',
contentClassName,
)}
editor={editor}
/>
</div>
);
}
function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps) {
const handleSetLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('URL', previousUrl ?? '');
if (url === null) {
return;
}
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
return (
<div
className={cn(
'bg-muted/40 flex flex-wrap items-center gap-0.5 border-b px-1 py-1',
disabled && 'pointer-events-none opacity-60',
)}
data-slot="markdown-editor-toolbar"
>
<Toggle
aria-label="Bold"
onPressedChange={() => editor.chain().focus().toggleBold().run()}
pressed={editor.isActive('bold')}
size="sm"
title="Bold (Ctrl+B)"
>
<Bold />
</Toggle>
<Toggle
aria-label="Italic"
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
pressed={editor.isActive('italic')}
size="sm"
title="Italic (Ctrl+I)"
>
<Italic />
</Toggle>
<Toggle
aria-label="Strikethrough"
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
pressed={editor.isActive('strike')}
size="sm"
title="Strikethrough"
>
<Strikethrough />
</Toggle>
<Toggle
aria-label="Inline code"
onPressedChange={() => editor.chain().focus().toggleCode().run()}
pressed={editor.isActive('code')}
size="sm"
title="Inline code"
>
<Code />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Heading 1"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
pressed={editor.isActive('heading', { level: 1 })}
size="sm"
title="Heading 1"
>
<Heading1 />
</Toggle>
<Toggle
aria-label="Heading 2"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
pressed={editor.isActive('heading', { level: 2 })}
size="sm"
title="Heading 2"
>
<Heading2 />
</Toggle>
<Toggle
aria-label="Heading 3"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
pressed={editor.isActive('heading', { level: 3 })}
size="sm"
title="Heading 3"
>
<Heading3 />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Bullet list"
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
pressed={editor.isActive('bulletList')}
size="sm"
title="Bullet list"
>
<List />
</Toggle>
<Toggle
aria-label="Ordered list"
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
pressed={editor.isActive('orderedList')}
size="sm"
title="Ordered list"
>
<ListOrdered />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Blockquote"
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
pressed={editor.isActive('blockquote')}
size="sm"
title="Blockquote"
>
<Quote />
</Toggle>
<Toggle
aria-label="Code block"
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
pressed={editor.isActive('codeBlock')}
size="sm"
title="Code block"
>
<Code2 />
</Toggle>
<Toggle
aria-label="Link"
onPressedChange={handleSetLink}
pressed={editor.isActive('link')}
size="sm"
title="Insert link"
>
<LinkIcon />
</Toggle>
<Toggle
aria-label="Horizontal rule"
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
pressed={false}
size="sm"
title="Horizontal rule"
>
<Minus />
</Toggle>
<div className="ml-auto flex items-center gap-0.5">
<Toggle
aria-label="Undo"
disabled={!editor.can().undo()}
onPressedChange={() => editor.chain().focus().undo().run()}
pressed={false}
size="sm"
title="Undo (Ctrl+Z)"
>
<Undo />
</Toggle>
<Toggle
aria-label="Redo"
disabled={!editor.can().redo()}
onPressedChange={() => editor.chain().focus().redo().run()}
pressed={false}
size="sm"
title="Redo (Ctrl+Shift+Z)"
>
<Redo />
</Toggle>
</div>
</div>
);
}
export { MarkdownEditor };
+278
View File
@@ -0,0 +1,278 @@
import bash from 'highlight.js/lib/languages/bash';
import c from 'highlight.js/lib/languages/c';
import csharp from 'highlight.js/lib/languages/csharp';
import dockerfile from 'highlight.js/lib/languages/dockerfile';
import go from 'highlight.js/lib/languages/go';
import graphql from 'highlight.js/lib/languages/graphql';
import http from 'highlight.js/lib/languages/http';
import java from 'highlight.js/lib/languages/java';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import kotlin from 'highlight.js/lib/languages/kotlin';
import lua from 'highlight.js/lib/languages/lua';
import markdown from 'highlight.js/lib/languages/markdown';
import nginx from 'highlight.js/lib/languages/nginx';
import php from 'highlight.js/lib/languages/php';
import python from 'highlight.js/lib/languages/python';
import sql from 'highlight.js/lib/languages/sql';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';
import 'highlight.js/styles/atom-one-dark.css';
import { common, createLowlight } from 'lowlight';
import { isValidElement, type ReactNode, useCallback, useMemo } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
const lowlight = createLowlight();
lowlight.register('bash', bash);
lowlight.register('c', c);
lowlight.register('csharp', csharp);
lowlight.register('dockerfile', dockerfile);
lowlight.register('go', go);
lowlight.register('graphql', graphql);
lowlight.register('http', http);
lowlight.register('java', java);
lowlight.register('javascript', javascript);
lowlight.register('json', json);
lowlight.register('kotlin', kotlin);
lowlight.register('lua', lua);
lowlight.register('markdown', markdown);
lowlight.register('nginx', nginx);
lowlight.register('php', php);
lowlight.register('python', python);
lowlight.register('sql', sql);
lowlight.register('xml', xml);
lowlight.register('yaml', yaml);
interface MarkdownProps {
children: string;
className?: string;
searchValue?: string;
}
const textElements = [
'p',
'span',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'a',
'li',
'ul',
'ol',
'blockquote',
'table',
'thead',
'tbody',
'tr',
'td',
'th',
'strong',
'em',
'b',
'i',
'u',
's',
'del',
'ins',
'mark',
'small',
'sub',
'sup',
'dl',
'dt',
'dd',
];
const escapeRegExp = (string: string): string => {
return string.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
function Markdown({ children, className, searchValue }: MarkdownProps) {
const processedSearch = useMemo(() => {
const trimmedSearch = searchValue?.trim();
if (!trimmedSearch) {
return null;
}
return {
escaped: escapeRegExp(trimmedSearch),
regex: new RegExp(`(${escapeRegExp(trimmedSearch)})`, 'gi'),
trimmed: trimmedSearch,
};
}, [searchValue]);
const createHighlightedText = useCallback(
(text: string) => {
if (!processedSearch) {
return text;
}
const parts = text.split(processedSearch.regex);
return parts.map((part, index) => {
if (part.toLowerCase() === processedSearch.trimmed.toLowerCase()) {
return (
<span
key={`highlight-${index}`}
style={{
backgroundColor: 'rgba(255, 255, 0, 0.15)',
borderRadius: '2px',
boxShadow: 'inset 0 0 0 1px rgba(255, 255, 0, 0.25)',
padding: '0px 1px',
}}
>
{part}
</span>
);
}
return part;
});
},
[processedSearch],
);
const processTextNode = useMemo(() => {
const hasChildrenProp = (
node: unknown,
): node is { key?: null | React.Key; props: { children: ReactNode } } =>
isValidElement(node) && (node.props as { children?: ReactNode }).children !== undefined;
const fn = (nodeChildren: ReactNode): ReactNode => {
if (!processedSearch) {
return nodeChildren;
}
if (typeof nodeChildren === 'string') {
return createHighlightedText(nodeChildren);
}
if (Array.isArray(nodeChildren)) {
return nodeChildren.map((child, index) => {
if (typeof child === 'string') {
return createHighlightedText(child);
}
if (hasChildrenProp(child)) {
return {
...child,
key: child.key || `processed-${index}`,
props: {
...child.props,
children: fn(child.props.children),
},
};
}
return child;
});
}
if (hasChildrenProp(nodeChildren)) {
return {
...nodeChildren,
props: {
...nodeChildren.props,
children: fn(nodeChildren.props.children),
},
};
}
return nodeChildren;
};
return fn;
}, [processedSearch, createHighlightedText]);
const createComponentRenderer = useCallback(
(ComponentName: string) => {
const Component = ComponentName as React.ElementType;
const Renderer = ({ children: nodeChildren, ...props }: Record<string, unknown>) => {
const processedChildren = processTextNode(nodeChildren);
return <Component {...props}>{processedChildren}</Component>;
};
Renderer.displayName = `Highlighted(${ComponentName})`;
return Renderer;
},
[processTextNode],
);
const customComponents = useMemo(() => {
const components: Components = {};
if (processedSearch) {
textElements.forEach((element) => {
(components as Record<string, ReturnType<typeof createComponentRenderer>>)[element] =
createComponentRenderer(element);
});
// Code blocks pass through untouched — highlighting injected `<span>`s into a `<code>`
// breaks rehype-highlight's tokenization and the rendered listing.
components.code = ({ children: nodeChildren, ...props }) => {
return <code {...props}>{nodeChildren}</code>;
};
components.pre = ({ children: nodeChildren, ...props }) => {
return <pre {...props}>{nodeChildren}</pre>;
};
}
return components;
}, [processedSearch, createComponentRenderer]);
return (
<div className={`prose prose-sm dark:prose-invert max-w-none ${className || ''}`}>
<ReactMarkdown
components={customComponents}
rehypePlugins={[
[
rehypeHighlight,
{
detect: true,
languages: {
...common,
bash,
c,
csharp,
dockerfile,
go,
graphql,
http,
java,
javascript,
json,
kotlin,
lua,
markdown,
nginx,
php,
python,
sql,
xml,
yaml,
},
},
],
rehypeSlug,
]}
remarkPlugins={[remarkGfm]}
>
{children}
</ReactMarkdown>
</div>
);
}
export default Markdown;
@@ -0,0 +1,549 @@
/**
* Monaco Terminal Component
*
* High-performance terminal log viewer based on Monaco Editor with ANSI color support.
* Uses the `anser` library for robust ANSI escape sequence parsing.
*
* Supported ANSI features (via anser):
* - SGR codes: Reset, Bold, Dim, Italic, Underline, Blink, Reverse, Hidden, Strikethrough
* - Standard and bright foreground/background colors (30-37, 40-47, 90-97, 100-107)
* - 256-color palette (38;5;N, 48;5;N)
* - True color / 24-bit RGB (38;2;R;G;B, 48;2;R;G;B)
* - Reverse video (proper fg/bg color swap handled by anser)
*
* Key optimizations:
* - Dynamic CSS class injection with deduplication for color support
* - IEditorDecorationsCollection for efficient decoration management
* - Module-level WeakMap cache for parsed logs (avoids re-parsing same logs array)
* - Memoized content extraction for better React performance
* - Incremental decoration updates (only decorates new lines)
* - Stable useCallback for mount handler
*
* @see https://microsoft.github.io/monaco-editor/docs.html for Monaco API reference
* @see https://github.com/IonicaBizau/anser for ANSI parser documentation
*/
import type * as monaco from 'monaco-editor';
import Editor, { type Monaco, type OnMount } from '@monaco-editor/react';
import Anser from 'anser';
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useTheme } from '@/hooks/use-theme';
import { Log } from '@/lib/log';
import { cn } from '@/lib/utils';
interface MonacoTerminalProps {
className?: string;
logs: string[];
searchValue?: string;
}
interface MonacoTerminalRef {
findNext: () => void;
findPrevious: () => void;
}
interface ParsedLine {
lineNumber: number;
segments: ParsedSegment[];
text: string;
}
interface ParsedSegment {
className: string;
endColumn: number;
startColumn: number;
}
/**
* Set of already injected CSS class names to prevent duplicate style injection.
*/
const injectedColorClasses = new Set<string>();
/**
* Converts an RGB string like "187, 0, 0" to a hex string "bb0000".
*/
const rgbStringToHex = (rgb: string): string =>
rgb
.split(',')
.map((part) =>
Math.min(255, Math.max(0, parseInt(part.trim(), 10)))
.toString(16)
.padStart(2, '0'),
)
.join('');
/**
* Returns the shared style element for dynamic color injection, creating it if needed.
*/
const getDynamicStyleElement = (): HTMLStyleElement => {
const styleId = 'monaco-terminal-dynamic-colors';
let element = document.getElementById(styleId) as HTMLStyleElement | null;
if (!element) {
element = document.createElement('style');
element.id = styleId;
document.head.appendChild(element);
}
return element;
};
/**
* Ensures a CSS class for the given RGB color exists, injecting it if needed.
* Returns the class name.
*/
const ensureColorClass = (prefix: string, rgb: string, cssProperty: string): string => {
const hex = rgbStringToHex(rgb);
const className = `${prefix}-${hex}`;
if (!injectedColorClasses.has(className)) {
getDynamicStyleElement().textContent += `.${className} { ${cssProperty}: rgb(${rgb}) !important; }\n`;
injectedColorClasses.add(className);
}
return className;
};
/**
* Maps anser decoration names to CSS class names.
*/
const DECORATION_CLASS_MAP: Record<string, string> = {
blink: 'ansi-blink',
bold: 'ansi-bold',
dim: 'ansi-dim',
hidden: 'ansi-hidden',
italic: 'ansi-italic',
strikethrough: 'ansi-strikethrough',
underline: 'ansi-underline',
};
/**
* Static CSS for text decoration and font style classes.
*/
const DECORATION_STYLES = [
'.ansi-bold { font-weight: bold !important; }',
'.ansi-dim { opacity: 0.7 !important; }',
'.ansi-italic { font-style: italic !important; }',
'.ansi-underline { text-decoration: underline !important; }',
'.ansi-strikethrough { text-decoration: line-through !important; }',
'.ansi-underline.ansi-strikethrough { text-decoration: underline line-through !important; }',
'.ansi-hidden { visibility: hidden !important; }',
'.ansi-blink { animation: ansi-blink 1s step-end infinite !important; }',
'@keyframes ansi-blink { 50% { opacity: 0; } }',
].join('\n');
/**
* Regex matching ANSI sequences and control characters not handled by anser.
* Anser only processes CSI SGR (ESC[...m). This regex matches everything else:
* - OSC sequences: ESC ] ... (BEL | ESC \)
* - DCS sequences: ESC P ... (ESC \)
* - Character set designations: ESC ( X, ESC ) X
* - DEC private sequences: ESC # N
* - Single-character escape codes: ESC followed by 7,8,D,M,E,H,c,N,O,Z,=,>,<
* - Lone ESC not followed by [ (catch-all for any remaining non-CSI escape)
* - Non-printable control characters (except \t and \n)
*/
const UNSUPPORTED_SEQUENCES_REGEX = new RegExp(
[
'\\x1b\\][\\s\\S]*?(?:\\x07|\\x1b\\\\)', // OSC: ESC ] ... (BEL | ST)
'\\x1bP[\\s\\S]*?\\x1b\\\\', // DCS: ESC P ... ST
'\\x1b[()][A-Z0-9]', // Character set: ESC ( X or ESC ) X
'\\x1b#[0-9]', // DEC screen alignment: ESC # N
'\\x1b[78DMEHcNOZ=>]', // Single-char escape sequences
'\\x1b(?!\\[)', // Lone ESC not starting a CSI sequence
'[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1a\\x1c-\\x1f\\x7f]', // Control chars (keep \t=0x09, \n=0x0a, \r=0x0d; skip ESC=0x1b)
].join('|'),
'g',
);
/**
* Sanitizes a single line before passing to anser:
* 1. Handles carriage returns (\r) — simulates terminal overwrite behavior
* by keeping only content after the last \r on the line
* 2. Strips non-SGR escape sequences and control characters
*/
const sanitizeTerminalLine = (line: string): string => {
// Handle carriage returns: keep only content after the last lone \r
// This simulates terminal overwrite (e.g. progress bars)
const lastCarriageReturn = line.lastIndexOf('\r');
const withoutCarriageReturns = lastCarriageReturn !== -1 ? line.substring(lastCarriageReturn + 1) : line;
return withoutCarriageReturns.replace(UNSUPPORTED_SEQUENCES_REGEX, '');
};
/**
* Parses a single line using anser and returns structured data for Monaco decorations.
* Anser handles all ANSI SGR codes including 256-color, true color, and reverse video.
* Non-SGR sequences and control characters are stripped before parsing.
*/
const parseAnsiLine = (line: string, lineNumber: number): ParsedLine => {
if (!line) {
return { lineNumber, segments: [], text: '' };
}
const sanitizedLine = sanitizeTerminalLine(line);
if (!sanitizedLine) {
return { lineNumber, segments: [], text: '' };
}
const entries = Anser.ansiToJson(sanitizedLine, { remove_empty: true });
const segments: ParsedSegment[] = [];
let column = 1;
for (const entry of entries) {
if (!entry.content) {
continue;
}
const startColumn = column;
const endColumn = column + entry.content.length;
column = endColumn;
const classNames: string[] = [];
if (entry.fg) {
classNames.push(ensureColorClass('ansi-fg', entry.fg, 'color'));
}
if (entry.bg) {
classNames.push(ensureColorClass('ansi-bg', entry.bg, 'background-color'));
}
for (const decoration of entry.decorations) {
const decorationClass = DECORATION_CLASS_MAP[decoration];
if (decorationClass) {
classNames.push(decorationClass);
}
}
if (classNames.length) {
segments.push({
className: classNames.join(' '),
endColumn,
startColumn,
});
}
}
const text = entries.map((entry) => entry.content).join('');
return { lineNumber, segments, text };
};
/**
* Module-level cache for parsed lines.
* Uses WeakMap to avoid memory leaks - when logs array is garbage collected, cache is cleaned.
*/
const parsedLogsCache = new WeakMap<readonly string[], ParsedLine[]>();
/**
* Parse logs array with caching.
* Returns cached result if the same logs array reference is passed.
*/
const parseLogsWithCache = (logs: string[]): ParsedLine[] => {
const cached = parsedLogsCache.get(logs);
if (cached) {
return cached;
}
const allLogsText = logs.join('\n');
const lines = allLogsText.split('\n');
const parsedLines = lines.map((line, index) => parseAnsiLine(line, index + 1));
parsedLogsCache.set(logs, parsedLines);
return parsedLines;
};
/**
* Terminal component based on Monaco Editor with ANSI color support.
* Provides a read-only code viewer with search functionality, theme support, and ANSI color rendering.
* Compatible with the existing Terminal component API.
*/
function MonacoTerminal({
className,
logs,
ref,
searchValue,
}: MonacoTerminalProps & { ref?: React.RefObject<MonacoTerminalRef | null> }) {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const { theme } = useTheme();
const [isEditorReady, setIsEditorReady] = useState(false);
const prevLogsLengthRef = useRef<number>(0);
const searchWidgetRef = useRef<null | { findNext: () => void; findPrevious: () => void }>(null);
const decorationsCollectionRef = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
const parsedContent = useMemo(() => parseLogsWithCache(logs), [logs]);
const content = useMemo(() => parsedContent.map((line) => line.text).join('\n'), [parsedContent]);
const handleEditorDidMount: OnMount = useCallback((editor, monacoInstance) => {
editorRef.current = editor;
monacoRef.current = monacoInstance;
setIsEditorReady(true);
editor.updateOptions({
automaticLayout: true,
folding: false,
glyphMargin: false,
lineDecorationsWidth: 0,
lineNumbers: 'on',
lineNumbersMinChars: 5,
minimap: { enabled: false },
overviewRulerLanes: 0, // Disable overview ruler for better performance
padding: { top: 4 },
readOnly: true,
renderLineHighlight: 'none',
renderWhitespace: 'none',
scrollbar: {
alwaysConsumeMouseWheel: false,
horizontal: 'hidden', // Hide horizontal scrollbar completely
useShadows: false,
vertical: 'visible',
verticalScrollbarSize: 10,
},
scrollBeyondLastLine: false,
selectOnLineNumbers: false,
wordWrap: 'on', // Enable word wrap (simple 'on' works better than 'bounded')
wrappingIndent: 'same',
wrappingStrategy: 'simple', // Use simple strategy for better performance
});
const ansiStyleId = 'monaco-terminal-ansi-styles';
if (!document.getElementById(ansiStyleId)) {
const ansiStyleElement = document.createElement('style');
ansiStyleElement.id = ansiStyleId;
ansiStyleElement.textContent = DECORATION_STYLES;
document.head.appendChild(ansiStyleElement);
}
const terminalStyleId = 'monaco-terminal-custom-styles';
if (!document.getElementById(terminalStyleId)) {
const terminalStyleElement = document.createElement('style');
terminalStyleElement.id = terminalStyleId;
terminalStyleElement.textContent = `
.monaco-editor .line-numbers {
padding-right: 12px !important;
}
.monaco-editor,
.monaco-editor .monaco-editor-background,
.monaco-editor .margin {
background-color: var(--background) !important;
}
`;
document.head.appendChild(terminalStyleElement);
}
searchWidgetRef.current = {
findNext: () => {
try {
editor.trigger('monaco-terminal', 'actions.find', {});
editor.trigger('monaco-terminal', 'editor.action.nextMatchFindAction', {});
} catch (error: unknown) {
Log.error('Monaco findNext failed:', error);
}
},
findPrevious: () => {
try {
editor.trigger('monaco-terminal', 'actions.find', {});
editor.trigger('monaco-terminal', 'editor.action.previousMatchFindAction', {});
} catch (error: unknown) {
Log.error('Monaco findPrevious failed:', error);
}
},
};
}, []);
useImperativeHandle(
ref,
() => ({
findNext: () => {
if (searchWidgetRef.current && editorRef.current) {
searchWidgetRef.current.findNext();
}
},
findPrevious: () => {
if (searchWidgetRef.current && editorRef.current) {
searchWidgetRef.current.findPrevious();
}
},
}),
[],
);
const decoratedLinesCountRef = useRef<number>(0);
useEffect(() => {
if (!isEditorReady || !editorRef.current || !monacoRef.current) {
return;
}
const editor = editorRef.current;
const monacoInstance = monacoRef.current;
try {
const decoratedCount = decoratedLinesCountRef.current;
if (parsedContent.length < decoratedCount) {
if (decorationsCollectionRef.current) {
decorationsCollectionRef.current.clear();
}
decoratedLinesCountRef.current = 0;
}
if (parsedContent.length <= decoratedLinesCountRef.current) {
return;
}
// Incremental: only decorate the tail of `parsedContent` past `decoratedLinesCountRef`.
// Re-applying the full set on every log append turns large terminals into a stutter
// because Monaco re-runs the decoration delta over every line.
const newDecorations: monaco.editor.IModelDeltaDecoration[] = [];
for (let index = decoratedLinesCountRef.current; index < parsedContent.length; index++) {
const line = parsedContent[index];
if (!line) {
continue;
}
for (const segment of line.segments) {
newDecorations.push({
options: {
inlineClassName: segment.className,
},
range: new monacoInstance.Range(
line.lineNumber,
segment.startColumn,
line.lineNumber,
segment.endColumn,
),
});
}
}
if (newDecorations.length) {
if (decorationsCollectionRef.current) {
decorationsCollectionRef.current.append(newDecorations);
} else {
decorationsCollectionRef.current = editor.createDecorationsCollection(newDecorations);
}
}
decoratedLinesCountRef.current = parsedContent.length;
} catch (error: unknown) {
Log.error('Monaco apply decorations failed:', error);
}
}, [parsedContent, isEditorReady]);
useEffect(() => {
if (!isEditorReady || !editorRef.current) {
return;
}
const editor = editorRef.current;
// Auto-scroll only on appends — not on the initial mount (`prev === 0`) and not when logs
// shrink (cleared), so we don't fight a user who scrolled up before more output arrives.
if (logs.length > prevLogsLengthRef.current && prevLogsLengthRef.current > 0) {
try {
const lineCount = editor.getModel()?.getLineCount() ?? 0;
if (lineCount > 0) {
editor.revealLine(lineCount, 1);
}
} catch (error: unknown) {
Log.error('Monaco scroll failed:', error);
}
}
prevLogsLengthRef.current = logs.length;
}, [logs, isEditorReady]);
useEffect(() => {
if (!isEditorReady || !editorRef.current || !monacoRef.current) {
return;
}
const editor = editorRef.current;
if (searchValue?.trim()) {
try {
const searchText = searchValue.trim();
const findOptions = {
isRegex: false,
matchCase: false,
preserveCase: false,
searchString: searchText,
wholeWord: false,
};
editor.trigger('monaco-terminal', 'actions.find', findOptions);
// rAF (instead of setTimeout) lets Monaco finish mounting the find widget before
// we issue the jump-to-first-match — fewer flaky races at log-bursty moments.
requestAnimationFrame(() => {
editor.trigger('monaco-terminal', 'editor.action.nextMatchFindAction', {});
});
} catch (error: unknown) {
Log.error('Monaco search failed:', error);
}
} else {
try {
editor.trigger('monaco-terminal', 'closeFindWidget', {});
} catch (error: unknown) {
Log.error('Monaco close find widget failed:', error);
}
}
}, [searchValue, isEditorReady]);
useEffect(() => {
return () => {
if (decorationsCollectionRef.current) {
decorationsCollectionRef.current.clear();
decorationsCollectionRef.current = null;
}
decoratedLinesCountRef.current = 0;
// Shared <style> tags are deliberately left attached — multiple instances share them
// and the WeakMap-backed parseLogsWithCache cleans itself up.
};
}, []);
return (
<div className={cn('relative size-full overflow-hidden', className)}>
<Editor
defaultLanguage="plaintext"
height="100%"
language="plaintext"
onMount={handleEditorDidMount}
options={{
domReadOnly: true,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
fontWeight: '600',
}}
theme={theme === 'dark' ? 'vs-dark' : 'vs-light'}
value={content}
width="100%"
/>
</div>
);
}
export default MonacoTerminal;
@@ -0,0 +1,5 @@
export { OverwriteButtons } from './overwrite-buttons';
export { OverwriteDialog } from './overwrite-dialog';
export type { OverwriteConflict } from './overwrite-dialog';
export { useOverwrite } from './use-overwrite';
export type { OverwriteOutcome } from './use-overwrite';
@@ -0,0 +1,83 @@
import type { ComponentType } from 'react';
import { Loader2, Replace } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface OverwriteButtonsProps {
/**
* When `true` both buttons are greyed-out and clicks are ignored. Use to
* disable the CTAs based on form validity, selection emptiness, or any
* other guard that doesn't carry a "request in flight" semantic.
*/
isDisabled?: boolean;
/**
* When `true` both buttons are still disabled, but the primary one
* additionally swaps its icon for a loading spinner. Wire this to the
* `is{Verb}ing` flag from the corresponding action hook.
*/
isProcessing?: boolean;
onOverwrite: () => void;
/** Optional primary action. When `primaryType="submit"` this is ignored and form submission is used instead. */
onPrimary?: () => void;
overwriteLabel: string;
primaryIcon: ComponentType<{ className?: string }>;
primaryLabel: string;
/**
* Render type for the primary button. Defaults to `"button"`. Pass `"submit"`
* when the dialog uses `react-hook-form` and the primary CTA should submit
* the form so validation runs first. The overwrite CTA is always a plain
* button — explicit `with overwrite` is intentional and never gated by form
* validation.
*/
primaryType?: 'button' | 'submit';
}
/**
* Two-CTA footer pattern shared by every overwrite-aware dialog (Pull, Attach,
* Promote, Move, Copy):
*
* [ Primary action ] [ Action with overwrite ]
*
* The primary action runs the dialog's regular flow (preflight → execute with
* `force=false`); the overwrite variant sends `force=true` straight away.
* Visually the latter uses the destructive variant so users can spot it from
* across the screen — overwriting is always destructive in our model.
*
* The component owns the spinner / icon swap, so callers don't repeat that
* boilerplate in five different dialogs.
*/
export function OverwriteButtons({
isDisabled,
isProcessing,
onOverwrite,
onPrimary,
overwriteLabel,
primaryIcon: PrimaryIcon,
primaryLabel,
primaryType = 'button',
}: OverwriteButtonsProps) {
const disabled = isDisabled || isProcessing;
return (
<>
<Button
disabled={disabled}
onClick={primaryType === 'submit' ? undefined : onPrimary}
type={primaryType}
>
{isProcessing ? <Loader2 className="animate-spin" /> : <PrimaryIcon />}
{primaryLabel}
</Button>
<Button
disabled={disabled}
onClick={onOverwrite}
type="button"
variant="destructive"
>
<Replace />
{overwriteLabel}
</Button>
</>
);
}
@@ -0,0 +1,87 @@
import { Replace } from 'lucide-react';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
export interface OverwriteConflict {
destination: string;
/** Display name extracted from `destination` for the confirm dialog. */
destinationName: string;
}
interface OverwriteDialogProps {
/**
* Overrides the auto-generated confirm button label. Defaults to
* `"Replace"` for a single conflict and `"Replace all"` for a batch.
*/
confirmText?: string;
/**
* Conflicts collected from a batch operation. Empty array keeps the dialog hidden.
* For a single conflict the message names the conflicting item; for many it falls
* back to a count-based summary (Finder-style "Apply to all").
*/
conflicts: OverwriteConflict[];
/**
* Optional override for the description body. When omitted, the component
* renders the canonical Finder-style copy that names the single conflicting
* item or the count for a batch.
*/
description?: string;
onCancel: () => void;
onReplaceAll: () => Promise<unknown> | unknown;
/** Optional override for the dialog title. Defaults to `"Replace existing item?"`. */
title?: string;
}
const buildDefaultDescription = (conflicts: OverwriteConflict[]): string | undefined => {
const single = conflicts.length === 1 ? conflicts[0] : undefined;
if (single) {
return `An item named "${single.destinationName}" already exists at /${single.destination}. Do you want to replace it?`;
}
if (conflicts.length > 1) {
return `${conflicts.length} items already exist at the destination. Do you want to replace all of them?`;
}
return undefined;
};
const buildDefaultConfirmText = (count: number): string => (count > 1 ? 'Replace all' : 'Replace');
/**
* Shared "Replace or cancel" confirmation for destructive overwrite flows
* (move / copy / pull / attach / promote, …). The hook owns the conflict
* state; this component only renders the prompt and forwards the user's
* decision back through the callbacks. A batch decision (Replace all) is
* applied to every pending conflict in one shot — this matches the OS
* file-manager UX and keeps the user from being prompted N times for the
* same destination directory.
*/
export function OverwriteDialog({
confirmText,
conflicts,
description,
onCancel,
onReplaceAll,
title = 'Replace existing item?',
}: OverwriteDialogProps) {
return (
<ConfirmationDialog
cancelText="Cancel"
confirmIcon={<Replace />}
confirmText={confirmText ?? buildDefaultConfirmText(conflicts.length)}
confirmVariant="destructive"
description={description ?? buildDefaultDescription(conflicts)}
handleConfirm={async () => {
await onReplaceAll();
}}
handleOpenChange={(nextOpen) => {
if (!nextOpen) {
onCancel();
}
}}
isOpen={conflicts.length > 0}
title={title}
/>
);
}
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { OverwriteConflict } from './overwrite-dialog';
/**
* Discriminated outcome of a server action that supports an overwrite flag.
* Hooks wrapping REST calls return this so the orchestrator can branch
* between success, a 409 that warrants a user prompt, and any other error
* (which the hook is expected to surface as a toast on its own).
*
* `conflict` carries an optional `conflicts` array — callers that fan out
* a batch into N parallel requests (e.g. multi-promote) can attach precise
* per-item descriptors here. When omitted, the hook falls back to
* `synthesizeFallbackConflicts` and finally to an anonymous descriptor so
* the dialog still opens with the count-based copy.
*/
export type OverwriteOutcome =
| { conflicts?: OverwriteConflict[]; kind: 'conflict' }
| { kind: 'error' }
| { kind: 'ok' };
/**
* Anonymous fallback descriptor used when a 409 sneaks through after a clean
* preflight and the caller didn't provide `synthesizeFallbackConflicts`. Falls
* back to the count-based copy ("N items already exist...") in
* `OverwriteDialog`.
*/
const ANONYMOUS_FALLBACK_CONFLICT: OverwriteConflict = {
destination: '',
destinationName: 'an item',
};
interface UseOverwriteOptions<TPlan> {
/**
* Execute the REST call. Receives the plan + a boolean `force` flag.
* Should return a discriminated outcome — see `OverwriteOutcome`.
*/
execute: (plan: TPlan, force: boolean) => Promise<OverwriteOutcome>;
/**
* Pure function: inspect the local snapshot and return any destinations
* that would conflict. Empty array → primary execute proceeds with
* `force=false`; non-empty → the OverwriteDialog is opened
* pre-populated with these descriptors.
*/
findConflicts: (plan: TPlan) => OverwriteConflict[];
/** Fired only when `execute` resolves with `kind: 'ok'`. */
onSuccess?: () => void;
/**
* Optional. Synthesizes conflict descriptors for the race-fallback case:
* preflight returned [] but the server still answered 409. Defaults to a
* single anonymous descriptor, which makes the dialog show the count-based
* copy ("Some items already exist…") instead of a per-item name.
*/
synthesizeFallbackConflicts?: (plan: TPlan) => OverwriteConflict[];
}
interface UseOverwriteResult<TPlan> {
/** Live conflict descriptors. Wire to `<OverwriteDialog conflicts={…} />`. */
conflicts: OverwriteConflict[];
/**
* Execute the action with `force=true` immediately, bypassing the
* preflight and the conflict prompt. Wire to the secondary CTA.
*/
forceExecute: (plan: TPlan) => Promise<void>;
/** Wire to the `onReplaceAll` handler of `<OverwriteDialog />`. */
handleReplaceAll: () => Promise<void>;
/**
* Execute the action with the preflight + race-fallback workflow. Wire
* to the primary CTA.
*/
primaryExecute: (plan: TPlan) => Promise<void>;
/** Wire to the `onCancel` handler of `<OverwriteDialog />`. */
resetConflicts: () => void;
}
/**
* Orchestrates the canonical "primary CTA / `… with overwrite` CTA / Replace
* all" workflow shared by the Pull, Attach, Promote, Move and Copy dialogs.
*
* Workflow:
* 1. The user clicks the **primary CTA** → `primaryExecute(plan)` runs.
* `findConflicts` is consulted on the local snapshot first; if anything
* collides, the OverwriteDialog opens with those descriptors.
* Otherwise `execute(plan, false)` is dispatched. A 409 from the server
* (race) auto-opens the dialog with `synthesizeFallbackConflicts` (or an
* anonymous fallback).
* 2. The user clicks the **`… with overwrite` CTA** → `forceExecute(plan)`
* runs `execute(plan, true)` straight away — no preflight, no prompt.
* 3. Inside the conflict dialog, **Replace all** → `handleReplaceAll()`
* retries the cached plan with `force=true`. **Cancel** →
* `resetConflicts()` only closes the prompt; the parent dialog stays
* open so the user can change the destination and re-submit.
*
* Callbacks passed in `options` are read through a ref, so callers don't need
* to wrap them in `useCallback`. This keeps the hook ergonomic at the call
* site without sacrificing reference stability for the returned actions.
*/
export function useOverwrite<TPlan>(options: UseOverwriteOptions<TPlan>): UseOverwriteResult<TPlan> {
const [conflicts, setConflicts] = useState<OverwriteConflict[]>([]);
const [pendingPlan, setPendingPlan] = useState<null | TPlan>(null);
// Read the latest options through a ref so callers can pass inline
// arrow functions without re-creating the action callbacks every render.
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
}, [options]);
const settle = useCallback(async (plan: TPlan, force: boolean): Promise<OverwriteOutcome> => {
const outcome = await optionsRef.current.execute(plan, force);
if (outcome.kind === 'ok') {
setConflicts([]);
setPendingPlan(null);
optionsRef.current.onSuccess?.();
}
return outcome;
}, []);
const primaryExecute = useCallback(
async (plan: TPlan): Promise<void> => {
const detected = optionsRef.current.findConflicts(plan);
if (detected.length > 0) {
setConflicts(detected);
setPendingPlan(plan);
return;
}
const outcome = await settle(plan, false);
if (outcome.kind !== 'conflict') {
return;
}
// Resolution chain (most → least specific):
// 1. precise descriptors returned by `execute` itself (e.g.
// multi-promote that fanned the batch into N parallel calls
// and knows exactly which ones came back as 409),
// 2. caller-supplied synthesizer derived from the plan,
// 3. an anonymous descriptor that triggers the dialog's
// count-based copy.
const fromOutcome = outcome.conflicts ?? [];
const synthesized = optionsRef.current.synthesizeFallbackConflicts?.(plan) ?? [];
let final: OverwriteConflict[];
if (fromOutcome.length > 0) {
final = fromOutcome;
} else if (synthesized.length > 0) {
final = synthesized;
} else {
final = [ANONYMOUS_FALLBACK_CONFLICT];
}
setConflicts(final);
setPendingPlan(plan);
},
[settle],
);
const forceExecute = useCallback(
async (plan: TPlan): Promise<void> => {
await settle(plan, true);
},
[settle],
);
const handleReplaceAll = useCallback(async (): Promise<void> => {
if (pendingPlan === null) {
return;
}
await settle(pendingPlan, true);
}, [pendingPlan, settle]);
const resetConflicts = useCallback(() => {
setConflicts([]);
setPendingPlan(null);
}, []);
return {
conflicts,
forceExecute,
handleReplaceAll,
primaryExecute,
resetConflicts,
};
}
@@ -0,0 +1,9 @@
function PageLoader() {
return (
<div className="grid h-screen w-full place-items-center">
<p>Loading...</p>
</div>
);
}
export default PageLoader;
@@ -0,0 +1,2 @@
export type { TerminalRef } from './terminal';
export { default } from './terminal';
@@ -0,0 +1,100 @@
import type { ITerminalOptions, ITheme } from '@xterm/xterm';
export const TERMINAL_OPTIONS: ITerminalOptions = {
allowProposedApi: true,
allowTransparency: true,
convertEol: true,
cursorBlink: false,
customGlyphs: true,
disableStdin: true,
fastScrollSensitivity: 10,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
fontWeight: 600,
logLevel: 'off',
screenReaderMode: false,
scrollback: 10_000,
smoothScrollDuration: 0,
} as const;
const DARK_THEME: ITheme = {
background: '#050c13',
black: '#f4f4f5',
blue: '#60a5fa',
brightBlack: '#e4e4e7',
brightBlue: '#93c5fd',
brightCyan: '#67e8f9',
brightGreen: '#86efac',
brightMagenta: '#d8b4fe',
brightRed: '#fca5a5',
brightWhite: '#71717a',
brightYellow: '#fde047',
cursor: '#f4f4f5',
cursorAccent: '#f4f4f5',
cyan: '#22d3ee',
foreground: '#f4f4f5',
green: '#4ade80',
magenta: '#c084fc',
red: '#f87171',
selectionBackground: 'rgba(96, 165, 250, 0.2)',
white: '#050c13',
yellow: '#facc15',
} as const;
const LIGHT_THEME: ITheme = {
background: '#ffffff',
black: '#020817',
blue: '#3b82f6',
brightBlack: '#64748b',
brightBlue: '#60a5fa',
brightCyan: '#22d3ee',
brightGreen: '#4ade80',
brightMagenta: '#c084fc',
brightRed: '#f87171',
brightWhite: '#f1f5f9',
brightYellow: '#facc15',
cursor: '#020817',
cursorAccent: '#020817',
cyan: '#06b6d4',
foreground: '#020817',
green: '#22c55e',
magenta: '#a855f7',
red: '#ef4444',
selectionBackground: 'rgba(59, 130, 246, 0.1)',
white: '#e2e8f0',
yellow: '#eab308',
} as const;
const DARK_SEARCH_DECORATIONS = {
activeMatchBackground: '#AAAAAA',
activeMatchColorOverviewRuler: '#000000',
matchBackground: '#666666',
matchOverviewRuler: '#000000',
} as const;
const LIGHT_SEARCH_DECORATIONS = {
activeMatchBackground: '#555555',
activeMatchColorOverviewRuler: '#000000',
matchBackground: '#000000',
matchOverviewRuler: '#000000',
} as const;
export function getSearchDecorations(isDark: boolean) {
return isDark ? DARK_SEARCH_DECORATIONS : LIGHT_SEARCH_DECORATIONS;
}
export function getTerminalTheme(isDark: boolean): ITheme {
return isDark ? DARK_THEME : LIGHT_THEME;
}
export function isDarkMode(theme: 'dark' | 'light' | 'system'): boolean {
if (theme === 'dark') {
return true;
}
if (theme === 'light') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
@@ -0,0 +1,728 @@
import { describe, expect, it } from 'vitest';
import { needsSanitization, processLog, sanitizeTerminalOutput } from './terminal-sanitizer';
// Helper: check that no C1 control bytes (0x80-0x9F) remain in output
function hasC1(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c >= 0x80 && c <= 0x9f) {
return true;
}
}
return false;
}
// Helper: check that no ESC byte (0x1B) remains in output
function hasEsc(s: string): boolean {
return s.includes('\x1b');
}
describe('needsSanitization', () => {
it('returns false for pure ASCII', () => {
expect(needsSanitization('Hello World 123 !@#')).toBe(false);
});
it('returns false for empty/null', () => {
expect(needsSanitization('')).toBe(false);
expect(needsSanitization(null as unknown as string)).toBe(false);
});
it('returns false for whitespace (TAB, LF, CR)', () => {
expect(needsSanitization('line1\nline2\ttab\rcarriage')).toBe(false);
});
it('returns false for valid Unicode (Cyrillic, CJK, Latin Extended)', () => {
expect(needsSanitization('Привет мир')).toBe(false);
expect(needsSanitization('你好世界')).toBe(false);
expect(needsSanitization('café résumé')).toBe(false);
});
it('returns false for box-drawing characters', () => {
expect(needsSanitization('━━━┃━━━')).toBe(false);
});
it('returns false for valid emoji (surrogate pairs)', () => {
expect(needsSanitization('Hello 🌍 World')).toBe(false);
expect(needsSanitization('😀😁😂')).toBe(false);
expect(needsSanitization('🇺🇸')).toBe(false);
});
it('returns true for ESC sequences', () => {
expect(needsSanitization('\x1b[31m')).toBe(true);
expect(needsSanitization('text\x1b')).toBe(true);
});
it('returns true for C0 control characters', () => {
expect(needsSanitization('\x00')).toBe(true);
expect(needsSanitization('\x07')).toBe(true);
expect(needsSanitization('text\x01more')).toBe(true);
});
it('returns true for C1 control characters', () => {
expect(needsSanitization('\x80')).toBe(true);
expect(needsSanitization('\x90')).toBe(true);
expect(needsSanitization('\x9b')).toBe(true);
});
it('returns true for DEL', () => {
expect(needsSanitization('\x7f')).toBe(true);
});
it('returns true for U+FFFD', () => {
expect(needsSanitization('\ufffd')).toBe(true);
});
it('returns true for lone surrogates', () => {
expect(needsSanitization('\uD83C')).toBe(true);
expect(needsSanitization('\uDFFF')).toBe(true);
expect(needsSanitization('text\uD800')).toBe(true);
});
});
describe('sanitizeTerminalOutput', () => {
describe('CSI SGR (colors/styles) — preserved', () => {
it('preserves SGR red', () => {
expect(processLog('\x1b[31m')).toBe('\x1b[31m');
});
it('preserves SGR reset', () => {
expect(processLog('\x1b[0m')).toBe('\x1b[0m');
});
it('preserves SGR bold+red', () => {
expect(processLog('\x1b[1;31m')).toBe('\x1b[1;31m');
});
it('preserves SGR 256-color', () => {
expect(processLog('\x1b[38;5;196m')).toBe('\x1b[38;5;196m');
});
it('preserves SGR RGB', () => {
expect(processLog('\x1b[38;2;255;0;0m')).toBe('\x1b[38;2;255;0;0m');
});
it('preserves SGR in context with surrounding text', () => {
const input = 'hello \x1b[31mred\x1b[0m normal';
expect(processLog(input)).toBe(input);
});
it('preserves multiple SGR sequences', () => {
const input = '\x1b[1m\x1b[31mBOLD RED\x1b[0m';
expect(processLog(input)).toBe(input);
});
});
describe('CSI non-SGR — blocked with body consumed', () => {
it('consumes ED: erase display', () => {
expect(processLog('a\x1b[2Jb')).toBe('a.b');
});
it('consumes ED: erase scrollback', () => {
expect(processLog('a\x1b[3Jb')).toBe('a.b');
});
it('consumes CUP: cursor home', () => {
expect(processLog('a\x1b[Hb')).toBe('a.b');
});
it('consumes SU: scroll up', () => {
expect(processLog('a\x1b[999Sb')).toBe('a.b');
});
it('consumes SD: scroll down', () => {
expect(processLog('a\x1b[999Tb')).toBe('a.b');
});
it('consumes DECSET: alternate buffer', () => {
expect(processLog('a\x1b[?1049hb')).toBe('a.b');
});
it('consumes DECSET: hide cursor', () => {
expect(processLog('a\x1b[?25lb')).toBe('a.b');
});
it('consumes DL: delete lines', () => {
expect(processLog('a\x1b[10Mb')).toBe('a.b');
});
it('consumes IL: insert lines', () => {
expect(processLog('a\x1b[10Lb')).toBe('a.b');
});
it('consumes ICH: insert characters', () => {
expect(processLog('a\x1b[10@b')).toBe('a.b');
});
it('preserves surrounding text when consuming CSI', () => {
expect(processLog('before\x1b[2Jafter')).toBe('before.after');
});
it('does not consume invalid CSI (no final byte)', () => {
const out = processLog('\x1b[');
expect(out).not.toContain('\x1b');
});
});
describe('CSI length limit', () => {
it('strips CSI exceeding MAX_CSI_LENGTH', () => {
const input = '\x1b[' + '1;'.repeat(1000) + 'm';
expect(processLog(input)).toMatch(/^\./);
expect(processLog(input)).not.toContain('\x1b');
});
});
describe('OSC 8 hyperlinks — safe protocols preserved', () => {
it('preserves https link', () => {
const input = '\x1b]8;;https://example.com\x07Click\x1b]8;;\x07';
expect(processLog(input)).toContain('\x1b]8;;https://example.com\x07');
});
it('preserves http link', () => {
const input = '\x1b]8;;http://example.com\x07Click\x1b]8;;\x07';
expect(processLog(input)).toContain('\x1b]8;;http://');
});
it('preserves mailto link', () => {
const input = '\x1b]8;;mailto:user@example.com\x07Click\x1b]8;;\x07';
expect(processLog(input)).toContain('\x1b]8;;mailto:');
});
it('preserves close tag (BEL terminated)', () => {
expect(processLog('\x1b]8;;\x07')).toBe('\x1b]8;;\x07');
});
it('preserves close tag (ST terminated)', () => {
expect(processLog('\x1b]8;;\x1b\\')).toBe('\x1b]8;;\x1b\\');
});
it('preserves OSC 8 with id param', () => {
const input = '\x1b]8;id=foo;https://example.com\x07Click\x1b]8;;\x07';
expect(processLog(input)).toContain('\x1b]8;id=foo;https://example.com');
});
it('preserves ssh link', () => {
const input = '\x1b]8;;ssh://user@host.com\x07Click\x1b]8;;\x07';
expect(processLog(input)).toContain('\x1b]8;;ssh://');
});
it('preserves telnet link', () => {
const input = '\x1b]8;;telnet://host:23\x07Click\x1b]8;;\x07';
expect(processLog(input)).toContain('\x1b]8;;telnet://');
});
it('preserves full link with ST terminator', () => {
const input = '\x1b]8;;https://example.com\x1b\\Click\x1b]8;;\x1b\\';
expect(processLog(input)).toContain('\x1b]8;;https://example.com\x1b\\');
});
it('preserves long URL up to MAX_OSC_LENGTH (2048)', () => {
const longPath = '/path/' + 'a'.repeat(500);
const input = '\x1b]8;;https://example.com' + longPath + '\x07Click\x1b]8;;\x07';
expect(processLog(input)).toContain('https://example.com' + longPath);
});
});
describe('OSC 8 hyperlinks — dangerous protocols blocked', () => {
it('consumes javascript: URI with body', () => {
const input = '\x1b]8;;javascript:alert(1)\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('javascript:');
expect(out).toContain('Click');
expect(out).toContain('\x1b]8;;\x07');
});
it('consumes data: URI with body', () => {
const input = '\x1b]8;;data:text/html,<h1>XSS</h1>\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('data:');
});
it('consumes ftp: URI with body', () => {
const input = '\x1b]8;;ftp://evil.com/shell\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('ftp:');
});
it('consumes file: URI with body', () => {
const input = '\x1b]8;;file:///etc/passwd\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('file:');
});
it('preserves safe close tag even when open tag is consumed', () => {
const input = '\x1b]8;;javascript:x\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).toContain('\x1b]8;;\x07');
});
it('consumes OSC 8 with missing second semicolon', () => {
const input = '\x1b]8;https://example.com\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('8;https:');
});
it('consumes OSC 8 with unknown protocol', () => {
const input = '\x1b]8;;custom://something\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('custom:');
});
});
describe('OSC non-hyperlink — blocked with body consumed', () => {
it('consumes OSC 0: window title', () => {
expect(processLog('\x1b]0;HACKED\x07')).toBe('.');
});
it('consumes OSC 2: window title', () => {
expect(processLog('\x1b]2;HACKED\x07')).toBe('.');
});
it('consumes OSC 10: foreground color', () => {
expect(processLog('\x1b]10;#ff0000\x07')).toBe('.');
});
it('consumes OSC 11: background color', () => {
expect(processLog('\x1b]11;#00ff00\x07')).toBe('.');
});
it('consumes OSC 12: cursor color', () => {
expect(processLog('\x1b]12;#0000ff\x07')).toBe('.');
});
it('consumes OSC 4: palette color', () => {
expect(processLog('\x1b]4;1;#ff0000\x07')).toBe('.');
});
it('preserves surrounding text when consuming OSC', () => {
expect(processLog('before\x1b]0;TITLE\x07after')).toBe('before.after');
});
});
describe('ESC simple sequences — all blocked', () => {
it('strips ESC 7 (save cursor)', () => {
expect(hasEsc(processLog('\x1b7'))).toBe(false);
});
it('strips ESC 8 (restore cursor)', () => {
expect(hasEsc(processLog('\x1b8'))).toBe(false);
});
it('strips ESC D (index)', () => {
expect(hasEsc(processLog('\x1bD'))).toBe(false);
});
it('strips ESC E (next line)', () => {
expect(hasEsc(processLog('\x1bE'))).toBe(false);
});
it('strips ESC M (reverse index)', () => {
expect(hasEsc(processLog('\x1bM'))).toBe(false);
});
it('strips ESC _ (APC) with body consumed', () => {
const out = processLog('\x1b_payload\x1b\\');
expect(hasEsc(out)).toBe(false);
expect(out).not.toContain('payload');
});
it('strips ESC ^ (PM) with body consumed', () => {
const out = processLog('\x1b^payload\x1b\\');
expect(hasEsc(out)).toBe(false);
expect(out).not.toContain('payload');
});
it('strips ESC X (SOS) with body consumed', () => {
const out = processLog('\x1bXpayload\x1b\\');
expect(hasEsc(out)).toBe(false);
expect(out).not.toContain('payload');
});
it('strips ESC P (DCS) with body consumed', () => {
const out = processLog('\x1bP0;1|data\x1b\\');
expect(hasEsc(out)).toBe(false);
expect(out).not.toContain('data');
});
it('strips ESC ( (charset G0)', () => {
expect(hasEsc(processLog('\x1b(B'))).toBe(false);
});
it('strips truncated ESC at end of input', () => {
expect(processLog('text\x1b')).toBe('text.');
});
});
describe('C0 control characters', () => {
it('preserves TAB', () => {
expect(processLog('a\tb')).toBe('a\tb');
});
it('preserves LF', () => {
expect(processLog('a\nb')).toBe('a\nb');
});
it('preserves CR', () => {
expect(processLog('a\rb')).toBe('a\rb');
});
it('strips NUL via binary detection', () => {
expect(processLog('x\x00y')).toBe('[binary data]');
});
it('strips BEL', () => {
expect(processLog('before\x07after')).toBe('before.after');
});
it('strips BS', () => {
expect(processLog('before\x08after')).toBe('before.after');
});
it('strips SOH, STX, ETX', () => {
expect(processLog('a\x01\x02\x03b')).toBe('a...b');
});
});
describe('C1 control characters (0x80-0x9F)', () => {
it('strips all C1 bytes', () => {
const padding = 'A'.repeat(400);
const c1Chars = String.fromCharCode(...Array.from({ length: 32 }, (_, i) => 0x80 + i));
const input = padding + c1Chars + 'end';
const out = processLog(input);
expect(hasC1(out)).toBe(false);
expect(out).toContain('end');
});
it('strips C1 DCS (0x90)', () => {
expect(processLog('a\x90b')).toBe('a.b');
});
it('strips C1 CSI (0x9B)', () => {
expect(processLog('a\x9bb')).toBe('a.b');
});
it('strips C1 OSC (0x9D)', () => {
expect(processLog('a\x9db')).toBe('a.b');
});
});
describe('DEL and U+FFFD', () => {
it('strips DEL (0x7F)', () => {
expect(processLog('a\x7fb')).toBe('a.b');
});
it('strips U+FFFD', () => {
expect(processLog('a\ufffdb')).toBe('a.b');
});
});
describe('Unicode preservation', () => {
it('preserves Cyrillic', () => {
expect(processLog('Привет мир')).toBe('Привет мир');
});
it('preserves CJK', () => {
expect(processLog('你好世界')).toBe('你好世界');
});
it('preserves Latin Extended', () => {
expect(processLog('café résumé')).toBe('café résumé');
});
it('preserves emoji', () => {
expect(processLog('Hello 🌍 World')).toBe('Hello 🌍 World');
});
it('preserves box-drawing', () => {
expect(processLog('━━━┃━━━')).toBe('━━━┃━━━');
});
it('preserves mixed Unicode with SGR', () => {
const input = '\x1b[31mПривет\x1b[0m 🌍';
expect(processLog(input)).toBe(input);
});
});
describe('binary content detection', () => {
it('detects null byte as binary', () => {
expect(processLog('text\x00more')).toBe('[binary data]');
});
it('detects lone surrogates as binary', () => {
const input = 'A'.repeat(100) + '\uD800' + 'B'.repeat(100);
expect(processLog(input)).toBe('[binary data]');
});
it('detects high control char density as binary', () => {
const input = Array.from({ length: 100 }, (_, i) => String.fromCharCode(i < 15 ? 0x01 + i : 0x41)).join('');
expect(processLog(input)).toBe('[binary data]');
});
it('does not false-positive on short strings with BEL', () => {
expect(processLog('\x1b]8;;\x07')).not.toBe('[binary data]');
});
it('does not false-positive on short strings without null', () => {
expect(processLog('\x07\x08\x01')).not.toBe('[binary data]');
});
it('passes through valid content after 512-byte sample window', () => {
const input = 'A'.repeat(600) + '\x90';
const out = processLog(input);
expect(out).not.toBe('[binary data]');
expect(hasC1(out)).toBe(false);
});
});
describe('edge cases', () => {
it('handles empty string', () => {
expect(sanitizeTerminalOutput('')).toBe('');
});
it('handles null', () => {
expect(sanitizeTerminalOutput(null as unknown as string)).toBe(null);
});
it('handles double ESC', () => {
expect(processLog('\x1b\x1b')).toBe('..');
});
it('handles ESC followed by non-printable', () => {
expect(processLog('\x1b\x01')).toBe('..');
});
it('handles ESC [ without final byte', () => {
const out = processLog('\x1b[');
expect(out).not.toContain('\x1b');
});
it('handles very long input without stack overflow', () => {
const input = '\x1b[31m' + 'A'.repeat(1_000_000) + '\x1b[0m';
const out = processLog(input);
expect(out).toContain('\x1b[31m');
expect(out.length).toBe(input.length);
});
});
describe('processLog fast path', () => {
it('skips sanitization for clean ASCII', () => {
const input = 'Hello World';
expect(processLog(input)).toBe(input);
});
it('skips sanitization for clean Unicode', () => {
const input = 'Привет мир 🌍';
expect(processLog(input)).toBe(input);
});
it('runs sanitization for strings with ESC', () => {
const input = '\x1b[31mred\x1b[0m';
expect(processLog(input)).toBe(input);
});
});
describe('string sequence body consumption (DCS/APC/PM/SOS)', () => {
it('consumes DCS body: ESC P body ESC \\ → single dot', () => {
expect(processLog('before\x1bPbody\x1b\\after')).toBe('before.after');
});
it('consumes APC body: ESC _ body ESC \\ → single dot', () => {
expect(processLog('before\x1b_body\x1b\\after')).toBe('before.after');
});
it('consumes PM body: ESC ^ body ESC \\ → single dot', () => {
expect(processLog('before\x1b^body\x1b\\after')).toBe('before.after');
});
it('consumes SOS body: ESC X body ESC \\ → single dot', () => {
expect(processLog('before\x1bXbody\x1b\\after')).toBe('before.after');
});
it('consumes DCS with C1 ST terminator (0x9C)', () => {
expect(processLog('before\x1bPbody\x9cafter')).toBe('before.after');
});
it('handles unterminated DCS — consumes until end of input', () => {
const out = processLog('before\x1bPbody');
expect(out).toBe('before.');
expect(out).not.toContain('body');
});
it('handles unterminated APC — consumes until end of input', () => {
const out = processLog('before\x1b_body');
expect(out).toBe('before.');
expect(out).not.toContain('body');
});
it('consumes DCS with ESC sequences inside body', () => {
const out = processLog('before\x1bP\x1b[31m\x1b\\after');
expect(out).toBe('before.after');
});
it('consumes long DCS body without hanging', () => {
const longBody = 'X'.repeat(50_000);
const out = processLog('a\x1bP' + longBody + '\x1b\\b');
expect(out).toBe('a.b');
});
});
describe('bidi override characters — stripped', () => {
it('strips RTL override U+202E', () => {
expect(processLog('hello\u202Eworld')).toBe('hello.world');
});
it('strips LTR mark U+200E', () => {
expect(processLog('a\u200Eb')).toBe('a.b');
});
it('strips RTL mark U+200F', () => {
expect(processLog('a\u200Fb')).toBe('a.b');
});
it('strips LTR embedding U+202A', () => {
expect(processLog('a\u202Ab')).toBe('a.b');
});
it('strips first-strong isolate U+2068', () => {
expect(processLog('a\u2068b')).toBe('a.b');
});
it('strips pop directional isolate U+2069', () => {
expect(processLog('a\u2069b')).toBe('a.b');
});
it('strips LTR isolate U+2066', () => {
expect(processLog('a\u2066b')).toBe('a.b');
});
it('strips multiple bidi controls in sequence', () => {
expect(processLog('a\u202E\u202D\u200Fb')).toBe('a...b');
});
it('preserves normal Unicode alongside bidi stripping', () => {
expect(processLog('Привет\u202Eмир')).toBe('Привет.мир');
});
it('preserves SGR alongside bidi stripping', () => {
const out = processLog('\x1b[31m\u202Etext\x1b[0m');
expect(out).toBe('\x1b[31m.text\x1b[0m');
});
});
describe('needsSanitization — bidi detection', () => {
it('returns true for RTL override U+202E', () => {
expect(needsSanitization('hello\u202Eworld')).toBe(true);
});
it('returns true for LTR mark U+200E', () => {
expect(needsSanitization('text\u200E')).toBe(true);
});
it('returns true for RTL mark U+200F', () => {
expect(needsSanitization('text\u200F')).toBe(true);
});
it('returns true for isolate U+2066', () => {
expect(needsSanitization('text\u2066')).toBe(true);
});
it('returns false for non-bidi Unicode above 0xA0', () => {
expect(needsSanitization('\u2010\u2014\u2026')).toBe(false);
});
});
describe('OSC 8 with Unicode URI', () => {
it('preserves OSC 8 link with Cyrillic path', () => {
const input = '\x1b]8;;https://example.com/путь\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).toContain('https://example.com/путь');
expect(out).toContain('\x1b]8;;https://example.com/путь\x07');
});
it('preserves OSC 8 link with CJK path', () => {
const input = '\x1b]8;;https://example.com/文档\x07Link\x1b]8;;\x07';
const out = processLog(input);
expect(out).toContain('https://example.com/文档');
});
it('rejects OSC with C1 control in content', () => {
const input = '\x1b]8;;\x90https://x\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('\x1b]8;;\x90');
});
it('rejects OSC with DEL in content', () => {
const input = '\x1b]8;;\x7fhttps://x\x07Click\x1b]8;;\x07';
const out = processLog(input);
expect(out).not.toContain('\x1b]8;;\x7f');
});
});
describe('performance', () => {
it('processes 1M chars in under 500ms', () => {
const input = '\x1b[31m' + 'A'.repeat(100_000) + '\x1b[0m';
const start = performance.now();
for (let i = 0; i < 100; i++) {
sanitizeTerminalOutput(input);
}
const duration = performance.now() - start;
expect(duration).toBeLessThan(500);
});
it('handles 1M ESC bytes without ReDoS', () => {
const input = '\x1b'.repeat(1_000_000);
const start = performance.now();
sanitizeTerminalOutput(input);
const duration = performance.now() - start;
expect(duration).toBeLessThan(1000);
});
});
});
@@ -0,0 +1,414 @@
const BINARY_SAMPLE_SIZE = 512;
const BINARY_CONTROL_RATIO = 0.1;
const MAX_CSI_LENGTH = 256;
const MAX_OSC_LENGTH = 2048;
const MAX_STRING_SEQUENCE_LENGTH = 100_000;
export const SAFE_PROTOCOLS = ['http://', 'https://', 'mailto:', 'ssh://', 'telnet://'];
/**
* Quick scan to determine if a string needs full sanitization.
* Returns false for strings that are purely ASCII printable + whitespace + valid Unicode.
*/
export function needsSanitization(input: string): boolean {
if (!input) {
return false;
}
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i);
if (code === 0x09 || code === 0x0a || code === 0x0d) {
continue;
}
if (code >= 0x20 && code <= 0x7e) {
continue;
}
if (code >= 0xa0) {
if (code >= 0xd800 && code <= 0xdbff) {
if (i + 1 < input.length) {
const next = input.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
i++;
continue;
}
}
return true;
}
if ((code >= 0xdc00 && code <= 0xdfff) || code === 0xfffd) {
return true;
}
if (isBidiControl(code)) {
return true;
}
continue;
}
return true;
}
return false;
}
export function processLog(log: string): string {
return needsSanitization(log) ? sanitizeTerminalOutput(log) : log;
}
/**
* Sanitizes terminal output for safe display in a read-only xterm.js terminal.
*
* Uses a slice-based approach: tracks ranges of safe characters and extracts
* them via input.slice() instead of pushing individual characters, reducing
* allocations from millions to thousands on large inputs.
*
* Preserves:
* - Valid Unicode text (Cyrillic, CJK, Latin Extended, emoji, etc.)
* - CSI SGR sequences (text color/style: ESC[...m)
* - OSC 8 hyperlinks with safe protocols (http, https, mailto, ssh, telnet)
*
* Strips:
* - C0/C1 control characters (except TAB, LF, CR)
* - All non-SGR CSI sequences (cursor movement, erase, scroll, DECSET)
* - OSC color/title/palette changes (OSC 0, 4, 10, 11, 12)
* - OSC 8 links with dangerous protocols (javascript:, data:, etc.)
* - All simple ESC sequences (cursor save/restore, index, charset)
* - DCS, APC, PM, SOS string sequences (body consumed until ST)
* - Binary content (replaced with placeholder)
*/
export function sanitizeTerminalOutput(input: string): string {
if (!input) {
return input;
}
if (isBinaryContent(input)) {
return '[binary data]';
}
const result: string[] = [];
let i = 0;
let safeStart = 0;
while (i < input.length) {
const code = input.charCodeAt(i);
// Fast path: ASCII printable (0x20-0x7E) and safe whitespace
if ((code >= 0x20 && code <= 0x7e) || code === 0x09 || code === 0x0a || code === 0x0d) {
i++;
continue;
}
// Valid Unicode (>= 0xA0, excluding lone surrogates, U+FFFD, and bidi controls)
if (code >= 0xa0) {
// Valid surrogate pair (emoji, supplementary chars) — skip both units
if (code >= 0xd800 && code <= 0xdbff) {
if (i + 1 < input.length) {
const next = input.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
i += 2;
continue;
}
}
// Lone/invalid surrogate — falls through to replacement below
} else if (code !== 0xfffd && !(code >= 0xdc00 && code <= 0xdfff) && !isBidiControl(code)) {
i++;
continue;
}
// Lone surrogates, U+FFFD, and bidi controls fall through to replacement below
}
// We hit a character that needs handling — flush the safe range
if (i > safeStart) {
result.push(input.slice(safeStart, i));
}
if (code === 0x1b) {
i++;
if (i >= input.length) {
result.push('.');
safeStart = i;
continue;
}
const next = input.charCodeAt(i);
// CSI sequence (ESC [) — only allow SGR (text style/color)
if (next === 0x5b) {
const parsed = parseCsiSequence(input, i + 1);
if (parsed.end !== -1 && parsed.final === 'm') {
result.push(input.slice(i - 1, parsed.end));
i = parsed.end;
safeStart = i;
continue;
}
if (parsed.end !== -1) {
i = parsed.end;
}
result.push('.');
safeStart = i;
continue;
}
// OSC sequence (ESC ]) — only allow safe OSC 8 hyperlinks
if (next === 0x5d) {
const end = parseOscSequence(input, i + 1);
if (end !== -1) {
const termLen = input.charCodeAt(end - 1) === 0x07 ? 1 : 2;
const oscContent = input.slice(i + 1, end - termLen);
if (isOscSafe(oscContent)) {
result.push(input.slice(i - 1, end));
i = end;
safeStart = i;
continue;
}
i = end;
}
result.push('.');
safeStart = i;
continue;
}
// DCS (ESC P), SOS (ESC X), PM (ESC ^), APC (ESC _) —
// consume the entire body up to ST terminator
if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
i = skipToST(input, i + 1);
result.push('.');
safeStart = i;
continue;
}
// All other ESC sequences (cursor save/restore, index, charset, etc.)
result.push('.');
safeStart = i;
continue;
}
// C0 controls, DEL, C1 controls, lone surrogates, U+FFFD
result.push('.');
i++;
safeStart = i;
}
if (safeStart < input.length) {
result.push(input.slice(safeStart));
}
return result.join('');
}
function isBidiControl(code: number): boolean {
return (
(code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069)
);
}
/**
* Heuristic detection of binary content in a JS string.
* Binary data (e.g. JPEG) interpreted as UTF-16 produces null bytes,
* lone surrogates, and dense clusters of control characters.
*
* For short strings (< 32 chars), only checks for null bytes to avoid
* false positives on terminal sequences containing BEL or other C0 codes.
*/
function isBinaryContent(input: string): boolean {
// Short strings: only null byte is a reliable binary indicator
if (input.length < 32) {
return input.includes('\x00');
}
let controlCount = 0;
const sampleSize = Math.min(input.length, BINARY_SAMPLE_SIZE);
for (let i = 0; i < sampleSize; i++) {
const code = input.charCodeAt(i);
// Null byte — immediate binary indicator
if (code === 0x00) {
return true;
}
// Lone surrogates indicate corrupted/binary data
if (code >= 0xd800 && code <= 0xdfff) {
if (code <= 0xdbff && i + 1 < input.length) {
const next = input.charCodeAt(i + 1);
// Valid surrogate pair — skip
if (next >= 0xdc00 && next <= 0xdfff) {
i++;
continue;
}
}
return true;
}
// Count C0 controls (except TAB, LF, CR, ESC which are common in terminal data)
if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x1b) {
controlCount++;
}
// Count C1 controls (0x80-0x9F)
if (code >= 0x80 && code <= 0x9f) {
controlCount++;
}
}
// High density of control characters indicates binary content
return controlCount / sampleSize > BINARY_CONTROL_RATIO;
}
/**
* Checks if a parsed OSC sequence content is safe for display.
* Only allows OSC 8 hyperlinks with safe protocols (http, https, mailto).
* Blocks OSC 0 (title), OSC 4/10/11/12 (color changes), and unsafe URIs.
*
* OSC 8 format: "8;params;uri" where params is optional key=value pairs.
* Empty URI (close tag "8;;") is always safe.
*/
function isOscSafe(content: string): boolean {
// Must be OSC 8 (hyperlink)
if (!content.startsWith('8;')) {
return false;
}
// Find the URI after the second semicolon: "8;params;uri"
const secondSemicolon = content.indexOf(';', 2);
if (secondSemicolon === -1) {
return false;
}
const uri = content.slice(secondSemicolon + 1);
// Empty URI = close tag (ESC]8;;BEL) — always safe
if (!uri) {
return true;
}
// Validate protocol against whitelist (blocks javascript:, data:, ftp:, file:, etc.)
const uriLower = uri.toLowerCase();
return SAFE_PROTOCOLS.some((protocol) => uriLower.startsWith(protocol));
}
/**
* Parses a CSI sequence (ESC [). Returns the end index and final byte,
* or end=-1 if invalid. Enforces a length limit to prevent unbounded scanning.
*
* CSI format: ESC [ [params] [intermediates] final_byte
* - Parameter bytes: 0x30-0x3F (digits, semicolons, etc.)
* - Intermediate bytes: 0x20-0x2F (space, !, ", etc.)
* - Final byte: 0x40-0x7E (letters — 'm' for SGR, 'H' for CUP, etc.)
*/
function parseCsiSequence(input: string, start: number): { end: number; final: string } {
let i = start;
while (i < input.length && i - start < MAX_CSI_LENGTH) {
const code = input.charCodeAt(i);
if (code < 0x20 || code > 0x7e) {
return { end: -1, final: '' };
}
i++;
if (code >= 0x40 && code <= 0x7e) {
return { end: i, final: String.fromCharCode(code) };
}
}
return { end: -1, final: '' };
}
/**
* Parses an OSC sequence (ESC ]). Terminated by BEL (0x07) or ST (ESC \).
* Returns end index (after terminator), or -1 if invalid.
*
* OSC format: ESC ] content BEL or ESC ] content ESC \
* Allows ASCII printables (0x20-0x7E) and Unicode >= 0xA0 per xterm.js parser
* (which accepts "any codepoint greater than C1 as printable").
* Blocks C0 controls (except BEL as terminator), DEL, and C1 controls (0x80-0x9F).
*/
function parseOscSequence(input: string, start: number): number {
let i = start;
while (i < input.length && i - start < MAX_OSC_LENGTH) {
const code = input.charCodeAt(i);
if (code === 0x07) {
return i + 1;
}
if (code === 0x1b && i + 1 < input.length && input.charAt(i + 1) === '\\') {
return i + 2;
}
if (code < 0x20 && code !== 0x07) {
return -1;
}
if (code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
return -1;
}
i++;
}
return -1;
}
/**
* Skips a string-type sequence body (DCS, SOS, PM, APC) until the ST terminator.
* ST is either ESC \ (7-bit) or the C1 byte 0x9C (8-bit).
* Returns the index after ST, or the end of input if unterminated.
* Enforces a length limit to prevent unbounded scanning.
*/
function skipToST(input: string, start: number): number {
let i = start;
const limit = Math.min(input.length, start + MAX_STRING_SEQUENCE_LENGTH);
while (i < limit) {
const code = input.charCodeAt(i);
if (code === 0x9c) {
return i + 1;
}
if (code === 0x1b && i + 1 < input.length && input.charCodeAt(i + 1) === 0x5c) {
return i + 2;
}
i++;
}
return i;
}
@@ -0,0 +1,90 @@
import { useEffect, useImperativeHandle, useRef } from 'react';
import { useTheme } from '@/hooks/use-theme';
import { cn } from '@/lib/utils';
import { processLog } from './terminal-sanitizer';
import { useTerminalSearch } from './use-terminal-search';
import { useXterm } from './use-xterm';
interface TerminalProps {
className?: string;
logs: string[];
searchValue?: string;
}
interface TerminalRef {
findNext: () => void;
findPrevious: () => void;
}
function Terminal({
className,
logs,
ref,
searchValue,
}: TerminalProps & { ref?: React.RefObject<null | TerminalRef> }) {
const { theme } = useTheme();
const { clear, containerRef, isReady, scrollToBottom, searchAddon, write } = useXterm({ theme });
const { findNext, findPrevious } = useTerminalSearch(searchAddon, isReady, searchValue, theme);
const lastLogIndexRef = useRef(0);
const prevLogsLengthRef = useRef(0);
useImperativeHandle(ref, () => ({ findNext, findPrevious }), [findNext, findPrevious]);
useEffect(() => {
if (!isReady) {
return;
}
if (logs.length === 0 && prevLogsLengthRef.current > 0) {
clear();
lastLogIndexRef.current = 0;
prevLogsLengthRef.current = 0;
return;
}
if (logs.length === 0) {
return;
}
if (logs.length >= lastLogIndexRef.current) {
const newLogs = logs.slice(lastLogIndexRef.current);
if (newLogs.length > 0) {
const batch = newLogs.filter(Boolean).map(processLog).join('\r\n');
if (batch) {
write(batch + '\r\n');
scrollToBottom();
}
}
} else {
clear();
const batch = logs.filter(Boolean).map(processLog).join('\r\n');
if (batch) {
write(batch + '\r\n');
}
scrollToBottom();
}
lastLogIndexRef.current = logs.length;
prevLogsLengthRef.current = logs.length;
}, [logs, isReady, write, clear, scrollToBottom]);
return (
<div
className={cn('overflow-hidden', className)}
ref={containerRef}
style={{ contain: 'strict' }}
/>
);
}
export type { TerminalRef };
export default Terminal;
@@ -0,0 +1,76 @@
import type { SearchAddon } from '@xterm/addon-search';
import { useCallback, useEffect } from 'react';
import { Log } from '@/lib/log';
import { getSearchDecorations, isDarkMode } from './terminal-config';
interface UseTerminalSearchResult {
findNext: () => void;
findPrevious: () => void;
}
export function useTerminalSearch(
searchAddon: null | SearchAddon,
isReady: boolean,
searchValue: string | undefined,
theme: 'dark' | 'light' | 'system',
): UseTerminalSearchResult {
useEffect(() => {
if (!searchAddon || !isReady) {
return;
}
try {
const trimmed = searchValue?.trim();
if (trimmed) {
searchAddon.findNext(trimmed, buildSearchOptions(isDarkMode(theme)));
} else {
searchAddon.clearDecorations();
}
} catch (error: unknown) {
Log.error('Terminal search failed:', error);
}
}, [searchAddon, isReady, searchValue, theme]);
const findNext = useCallback(() => {
const trimmed = searchValue?.trim();
if (!searchAddon || !trimmed) {
return;
}
try {
searchAddon.findNext(trimmed, buildSearchOptions(isDarkMode(theme)));
} catch (error: unknown) {
Log.error('Terminal findNext failed:', error);
}
}, [searchAddon, searchValue, theme]);
const findPrevious = useCallback(() => {
const trimmed = searchValue?.trim();
if (!searchAddon || !trimmed) {
return;
}
try {
searchAddon.findPrevious(trimmed, buildSearchOptions(isDarkMode(theme)));
} catch (error: unknown) {
Log.error('Terminal findPrevious failed:', error);
}
}, [searchAddon, searchValue, theme]);
return { findNext, findPrevious };
}
function buildSearchOptions(isDark: boolean) {
return {
caseSensitive: false,
decorations: getSearchDecorations(isDark),
regex: false,
wholeWord: false,
} as const;
}
@@ -0,0 +1,346 @@
import '@xterm/xterm/css/xterm.css';
import type { ILinkHandler } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { SearchAddon } from '@xterm/addon-search';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Log } from '@/lib/log';
import { isMac } from '@/lib/utils/platform';
import { getTerminalTheme, isDarkMode, TERMINAL_OPTIONS } from './terminal-config';
import { SAFE_PROTOCOLS } from './terminal-sanitizer';
const FLOW_CONTROL_CHUNK_SIZE = 64 * 1024;
const DEBOUNCE_DELAY = 150;
const TOOLTIP_CLASS = 'terminal-link-tooltip';
export interface UseXtermResult {
clear: () => void;
containerRef: React.RefObject<HTMLDivElement | null>;
isReady: boolean;
scrollToBottom: () => void;
searchAddon: null | SearchAddon;
write: (data: string) => void;
}
/**
* Manages the full xterm.js lifecycle: creation, addon loading,
* resize handling, WebGL fallback, theme sync, and cleanup.
*
* Implements chunked flow control for large writes per
* https://xtermjs.org/docs/guides/flowcontrol/
*
* Requires Ctrl+Click (Cmd+Click on Mac) to open links per
* https://xtermjs.org/docs/guides/link-handling/
*/
export function useXterm({ theme }: { theme: 'dark' | 'light' | 'system' }): UseXtermResult {
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<null | Terminal>(null);
const themeRef = useRef(theme);
const [isReady, setIsReady] = useState(false);
const [searchAddon, setSearchAddon] = useState<null | SearchAddon>(null);
useEffect(() => {
themeRef.current = theme;
}, [theme]);
const write = useCallback((data: string) => {
const terminal = terminalRef.current;
if (!terminal) {
return;
}
try {
writeWithFlowControl(terminal, data);
} catch (error: unknown) {
Log.error('Terminal write failed:', error);
}
}, []);
const clear = useCallback(() => {
try {
terminalRef.current?.clear();
} catch (error: unknown) {
Log.error('Terminal clear failed:', error);
}
}, []);
const scrollToBottom = useCallback(() => {
try {
terminalRef.current?.scrollToBottom();
} catch (error: unknown) {
Log.error('Terminal scrollToBottom failed:', error);
}
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let mounted = true;
const terminal = new Terminal({
...TERMINAL_OPTIONS,
theme: getTerminalTheme(isDarkMode(themeRef.current)),
});
const fitAddon = new FitAddon();
const search = new SearchAddon();
const unicodeAddon = new Unicode11Addon();
const mac = isMac();
const openLink = (event: MouseEvent, uri: string) => {
const uriLower = uri.toLowerCase();
if ((mac ? event.metaKey : event.ctrlKey) && SAFE_PROTOCOLS.some((p) => uriLower.startsWith(p))) {
window.open(uri, '_blank', 'noopener,noreferrer');
}
};
const linkHandler: ILinkHandler = {
activate: (event, text) => openLink(event, text),
allowNonHttpProtocols: true,
hover: (event, text) => showLinkTooltip(container, event, text, mac),
leave: () => removeLinkTooltip(container),
};
terminal.options.linkHandler = linkHandler;
const webLinksAddon = new WebLinksAddon(openLink, {
hover: (event, text) => showLinkTooltip(container, event, text, mac),
leave: () => removeLinkTooltip(container),
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(search);
terminal.loadAddon(unicodeAddon);
terminal.unicode.activeVersion = '11';
terminal.loadAddon(webLinksAddon);
const disposables: Array<{ dispose: () => void }> = [unicodeAddon, webLinksAddon];
try {
const webglAddon = new WebglAddon();
terminal.loadAddon(webglAddon);
disposables.push(webglAddon);
disposables.push(
webglAddon.onContextLoss(() => {
try {
webglAddon.dispose();
} catch {
/* ignore */
}
}),
);
} catch {
/* WebGL not available — canvas renderer is used as fallback */
}
const safeFit = () => {
try {
if (container.offsetHeight > 0) {
fitAddon.fit();
}
} catch (error: unknown) {
Log.error('Terminal fit failed:', error);
}
};
let debounceTimer: null | ReturnType<typeof setTimeout> = null;
const resizeObserver = new ResizeObserver(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(safeFit, DEBOUNCE_DELAY);
});
terminalRef.current = terminal;
const rafId = requestAnimationFrame(() => {
if (!mounted) {
return;
}
try {
terminal.open(container);
resizeObserver.observe(container);
safeFit();
setSearchAddon(search);
setIsReady(true);
} catch (error: unknown) {
Log.error('Failed to open terminal:', error);
}
});
return () => {
mounted = false;
cancelAnimationFrame(rafId);
if (debounceTimer) {
clearTimeout(debounceTimer);
}
removeLinkTooltip(container);
resizeObserver.disconnect();
for (const d of disposables) {
try {
d.dispose();
} catch {
/* ignore */
}
}
try {
search.dispose();
} catch {
/* ignore */
}
try {
fitAddon.dispose();
} catch {
/* ignore */
}
try {
terminal.dispose();
} catch {
/* ignore */
}
terminalRef.current = null;
setSearchAddon(null);
setIsReady(false);
};
}, []);
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
return;
}
const applyTheme = () => {
try {
terminal.options.theme = getTerminalTheme(isDarkMode(theme));
} catch (error: unknown) {
Log.error('Terminal theme update failed:', error);
}
};
applyTheme();
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme();
mediaQuery.addEventListener('change', handler);
return () => {
mediaQuery.removeEventListener('change', handler);
};
}
}, [theme]);
return { clear, containerRef, isReady, scrollToBottom, searchAddon, write };
}
function removeLinkTooltip(container: HTMLElement): void {
const existing = container.querySelector(`.${TOOLTIP_CLASS}`);
if (existing) {
existing.remove();
}
}
function showLinkTooltip(container: HTMLElement, event: MouseEvent, uri: string, mac: boolean): void {
removeLinkTooltip(container);
const tooltip = document.createElement('div');
tooltip.className = `${TOOLTIP_CLASS} xterm-hover`;
tooltip.style.cssText =
'position:absolute;z-index:10;padding:4px 8px;max-width:80%;font-size:12px;' +
'line-height:1.4;border-radius:4px;pointer-events:none;word-break:break-all;' +
'background:var(--popover);color:var(--popover-foreground);' +
'border:1px solid var(--border);box-shadow:0 2px 8px rgba(0,0,0,.15)';
const urlSpan = document.createElement('span');
urlSpan.textContent = uri;
tooltip.appendChild(urlSpan);
const hint = document.createElement('div');
hint.style.cssText = 'opacity:0.6;font-size:11px;margin-top:2px';
hint.textContent = `${mac ? 'Cmd' : 'Ctrl'}+Click to open`;
tooltip.appendChild(hint);
container.style.position = 'relative';
container.appendChild(tooltip);
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
let y = event.clientY - rect.top + 20;
tooltip.style.left = `${Math.min(x, rect.width - tooltip.offsetWidth - 8)}px`;
if (y + tooltip.offsetHeight > rect.height) {
y = event.clientY - rect.top - tooltip.offsetHeight - 8;
}
tooltip.style.top = `${Math.max(0, y)}px`;
}
/**
* Writes data to the terminal with chunked flow control.
* For large payloads, splits into chunks and uses xterm's write callback
* to avoid overwhelming the parser and blocking the UI thread.
*
* Chunk boundaries are adjusted to avoid splitting UTF-16 surrogate pairs:
* if the last code unit of a chunk is a high surrogate (0xD800-0xDBFF),
* the boundary is moved back by one so the pair stays intact.
*
* See: https://xtermjs.org/docs/guides/flowcontrol/
*/
function writeWithFlowControl(terminal: Terminal, data: string): void {
if (data.length <= FLOW_CONTROL_CHUNK_SIZE) {
terminal.write(data);
return;
}
let offset = 0;
const writeNext = () => {
let end = Math.min(offset + FLOW_CONTROL_CHUNK_SIZE, data.length);
if (end < data.length) {
const code = data.charCodeAt(end - 1);
if (code >= 0xd800 && code <= 0xdbff) {
end--;
}
}
const chunk = data.slice(offset, end);
offset = end;
if (offset < data.length) {
terminal.write(chunk, writeNext);
} else {
terminal.write(chunk);
}
};
writeNext();
}
@@ -0,0 +1,4 @@
export { UnsavedChangesDialog } from './unsaved-changes-dialog';
export type { UnsavedChangesDialogProps } from './unsaved-changes-dialog';
export { useUnsavedChangesGuard } from './use-unsaved-changes-guard';
export type { UnsavedChangesGuard, UseUnsavedChangesGuardArgs } from './use-unsaved-changes-guard';
@@ -0,0 +1,100 @@
import type { ReactNode } from 'react';
import { Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Spinner } from '@/components/ui/spinner';
export interface UnsavedChangesDialogProps {
/** When `false`, the "Save & leave" button is disabled (e.g. form is invalid). */
canSave: boolean;
description?: string;
discardText?: string;
handleCancel: () => void;
handleDiscard: () => void;
handleOpenChange: (open: boolean) => void;
handleSaveAndLeave: () => Promise<void> | void;
isOpen: boolean;
isSavingFromDialog: boolean;
/** Override the default `<Save />` icon next to the save button. */
saveIcon?: ReactNode;
saveText?: string;
title?: string;
}
function UnsavedChangesDialog({
canSave,
description = 'You have unsaved changes on this page. Would you like to save them before leaving?',
discardText = 'Discard',
handleCancel,
handleDiscard,
handleOpenChange,
handleSaveAndLeave,
isOpen,
isSavingFromDialog,
saveIcon = <Save />,
saveText = 'Save',
title = 'Unsaved changes',
}: UnsavedChangesDialogProps) {
return (
<Dialog
onOpenChange={handleOpenChange}
open={isOpen}
>
<DialogContent
className="sm:max-w-md"
onEscapeKeyDown={(event) => {
if (isSavingFromDialog) {
event.preventDefault();
}
}}
onInteractOutside={(event) => {
if (isSavingFromDialog) {
event.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
disabled={isSavingFromDialog}
onClick={handleCancel}
variant="outline"
>
Cancel
</Button>
<Button
disabled={isSavingFromDialog}
onClick={handleDiscard}
variant="destructive"
>
{discardText}
</Button>
<Button
disabled={isSavingFromDialog || !canSave}
onClick={() => {
void handleSaveAndLeave();
}}
variant="default"
>
{isSavingFromDialog ? <Spinner variant="circle" /> : saveIcon}
{saveText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { UnsavedChangesDialog };
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { type BlockerFunction, useBlocker } from 'react-router-dom';
export interface UnsavedChangesGuard {
handleCancel: () => void;
handleDiscard: () => void;
handleOpenChange: (open: boolean) => void;
handleSaveAndLeave: () => Promise<void>;
isOpen: boolean;
isSavingFromDialog: boolean;
/**
* Allows the next router navigation to bypass the blocker. Use this after
* a successful save when the consumer needs to navigate to a fresh URL
* (e.g. the new document page) without showing the dialog.
*/
skipNextBlock: () => void;
}
export interface UseUnsavedChangesGuardArgs {
isDirty: boolean;
isFormValid: boolean;
onSave: () => Promise<boolean>;
}
/**
* Generic "are you sure you want to leave?" guard for any editing form
* inside React Router. Combines two mechanisms:
*
* 1. `useBlocker` — intercepts in-app router navigations and exposes a
* blocked state that the consumer can render as a dialog.
* 2. `beforeunload` — covers full page reloads and tab close attempts;
* the browser shows its native prompt while there are dirty changes.
*
* Designed to be UI-agnostic: pair with `<UnsavedChangesDialog>` (or any
* custom dialog) by wiring the returned handlers.
*/
export function useUnsavedChangesGuard({
isDirty,
isFormValid,
onSave,
}: UseUnsavedChangesGuardArgs): UnsavedChangesGuard {
const allowNextRef = useRef(false);
const isDirtyRef = useRef(isDirty);
useEffect(() => {
isDirtyRef.current = isDirty;
}, [isDirty]);
const blockerFn = useCallback<BlockerFunction>(({ currentLocation, nextLocation }) => {
if (allowNextRef.current) {
allowNextRef.current = false;
return false;
}
if (currentLocation.pathname === nextLocation.pathname && currentLocation.search === nextLocation.search) {
return false;
}
return isDirtyRef.current;
}, []);
const blocker = useBlocker(blockerFn);
const [isSavingFromDialog, setIsSavingFromDialog] = useState(false);
useEffect(() => {
if (!isDirty) {
return;
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [isDirty]);
const isOpen = blocker.state === 'blocked';
const handleCancel = useCallback(() => {
if (blocker.state === 'blocked') {
blocker.reset();
}
}, [blocker]);
const handleDiscard = useCallback(() => {
if (blocker.state === 'blocked') {
blocker.proceed();
}
}, [blocker]);
const handleSaveAndLeave = useCallback(async () => {
if (isSavingFromDialog || !isFormValid) {
return;
}
setIsSavingFromDialog(true);
try {
const success = await onSave();
if (success && blocker.state === 'blocked') {
blocker.proceed();
}
} finally {
setIsSavingFromDialog(false);
}
}, [blocker, isFormValid, isSavingFromDialog, onSave]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (isSavingFromDialog) {
return;
}
if (!open && blocker.state === 'blocked') {
blocker.reset();
}
},
[blocker, isSavingFromDialog],
);
const skipNextBlock = useCallback(() => {
allowNextRef.current = true;
}, []);
return {
handleCancel,
handleDiscard,
handleOpenChange,
handleSaveAndLeave,
isOpen,
isSavingFromDialog,
skipNextBlock,
};
}
+46
View File
@@ -0,0 +1,46 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
function AccordionContent({ children, className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
className={cn('border-b', className)}
{...props}
/>
);
}
function AccordionTrigger({ children, className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
className={cn(
'flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+49
View File
@@ -0,0 +1,49 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-3 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
defaultVariants: {
variant: 'default',
},
variants: {
variant: {
default: 'bg-background text-foreground',
destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
},
);
function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
className={cn(alertVariants({ variant }), className)}
role="alert"
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'h5'>) {
return (
<h5
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
+597
View File
@@ -0,0 +1,597 @@
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import * as React from 'react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { Input } from '@/components/ui/input';
import { Popover, PopoverAnchor } from '@/components/ui/popover';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { cn } from '@/lib/utils';
interface AutocompleteContextValue {
commit: (value: string) => void;
inputId: string;
inputRef: React.RefObject<HTMLInputElement | null>;
inputValue: string;
open: boolean;
openOnFocus: boolean;
setInputValue: (value: string) => void;
setOpen: (open: boolean) => void;
}
/**
* Free-text input with a popover dropdown of substring-filtered suggestions.
*
* Built on top of `cmdk` (keyboard navigation) and Radix Popover (positioning,
* outside-click, escape). The visible field is a real text input — unlike the
* shadcn `Combobox` pattern, the user can type arbitrary values that are not
* in the suggestion list.
*
* Filtering defaults to a case-insensitive substring match against item
* `value` and `keywords`. Pass a custom `filter` prop (e.g. cmdk's
* `command-score` fuzzy matcher) when subsequence matching is desired.
*
* @example
* <Autocomplete value={path} onValueChange={setPath} onCommit={navigateTo}>
* <AutocompleteInput placeholder="/work" />
* <AutocompleteContent>
* <AutocompleteEmpty>No matches</AutocompleteEmpty>
* <AutocompleteGroup heading="Known paths">
* {paths.map((p) => (
* <AutocompleteItem key={p} value={p}>{p}</AutocompleteItem>
* ))}
* </AutocompleteGroup>
* </AutocompleteContent>
* </Autocomplete>
*/
/**
* Radix-style controllable state. When `controlled` is `undefined` the hook
* owns the state; otherwise the parent does and we forward updates via
* `onChange`. `onChange` always fires so fully-controlled consumers can
* observe every set (e.g. for logging).
*
* Mirrors `@radix-ui/react-use-controllable-state` without pulling in the
* dependency for a couple of state slots.
*/
function useControllable<T>(controlled: T | undefined, defaultValue: T, onChange?: (value: T) => void) {
const [internal, setInternal] = React.useState<T>(defaultValue);
const isControlled = controlled !== undefined;
const value = isControlled ? (controlled as T) : internal;
const onChangeRef = useLatestRef(onChange);
const set = React.useCallback(
(next: T) => {
if (!isControlled) {
setInternal(next);
}
onChangeRef.current?.(next);
},
[isControlled, onChangeRef],
);
return [value, set] as const;
}
const AutocompleteContext = React.createContext<AutocompleteContextValue | null>(null);
function useAutocompleteContext(): AutocompleteContextValue {
const ctx = React.useContext(AutocompleteContext);
if (!ctx) {
throw new Error('Autocomplete sub-components must be rendered inside <Autocomplete>.');
}
return ctx;
}
// Case-insensitive substring matcher used as the default. cmdk ships
// `command-score` (fuzzy/subsequence), which is great for command palettes
// but surprising in plain autocomplete usage — e.g. typing `baa` would still
// highlight `bash` because the characters appear in order. Substring matching
// is the conventional autocomplete contract: results contain the typed text
// somewhere, or they don't appear at all. Consumers that need fuzzy semantics
// can opt back in via the `filter` prop.
const substringFilter = (value: string, search: string, keywords?: string[]): number => {
const needle = search.trim().toLowerCase();
if (!needle) {
return 1;
}
if (value.toLowerCase().includes(needle)) {
return 1;
}
if (keywords?.some((kw) => kw.toLowerCase().includes(needle))) {
return 1;
}
return 0;
};
export type AutocompleteContentProps = React.ComponentProps<typeof PopoverPrimitive.Content> & {
/**
* Class name for the inner cmdk `List`. Use this to override the default
* max-height (`max-h-[280px]`) per usage site.
*/
listClassName?: string;
};
export type AutocompleteEmptyProps = React.ComponentProps<typeof CommandEmpty>;
export type AutocompleteGroupProps = React.ComponentProps<typeof CommandGroup>;
export type AutocompleteInputProps = Omit<
React.ComponentProps<'input'>,
'defaultValue' | 'onChange' | 'type' | 'value'
>;
export type AutocompleteItemProps = Omit<React.ComponentProps<typeof CommandItem>, 'onSelect' | 'value'> & {
/**
* Optional custom select handler. When provided, you become responsible
* for committing the value (the default behaviour is a no-op so you can
* decide whether to commit, navigate, etc.). When omitted, the item
* commits its own `value` via `Autocomplete.onCommit`.
*/
onSelect?: (value: string) => void;
/**
* The value committed when this item is chosen. Also fed into cmdk's
* filter (so make sure it matches what the user is likely to type).
*/
value: string;
};
export interface AutocompleteProps {
children: React.ReactNode;
defaultOpen?: boolean;
defaultValue?: string;
/**
* Custom matcher forwarded to `cmdk`. Returns `0..1` (1 = best match,
* 0 = hidden). Defaults to a case-insensitive substring match against
* the item `value` (and any provided `keywords`); pass cmdk's
* `command-score` here to opt back into fuzzy/subsequence matching.
*/
filter?: (value: string, search: string, keywords?: string[]) => number;
/**
* Called when the user commits a value:
* - selecting a suggestion (commits the item's `value`),
* - pressing Enter without an active suggestion (commits the raw input).
*
* Selecting always closes the popover; Enter without a match also closes it.
*/
onCommit?: (value: string) => void;
onOpenChange?: (open: boolean) => void;
onValueChange?: (value: string) => void;
open?: boolean;
/**
* Whether to open the popover when the input gains focus. Defaults to `true`.
* Set to `false` to only open on typing.
*/
openOnFocus?: boolean;
/**
* Disable cmdk's built-in filtering — useful when the consumer pre-filters
* the items themselves (e.g. async server-side suggestions).
*/
shouldFilter?: boolean;
value?: string;
}
export type AutocompleteSeparatorProps = React.ComponentProps<typeof CommandSeparator>;
function Autocomplete({
children,
defaultOpen = false,
defaultValue = '',
filter,
onCommit,
onOpenChange,
onValueChange,
open: openProp,
openOnFocus = true,
shouldFilter,
value: valueProp,
}: AutocompleteProps) {
const [inputValue, setInputValue] = useControllable<string>(valueProp, defaultValue, onValueChange);
const [open, setOpen] = useControllable<boolean>(openProp, defaultOpen, onOpenChange);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const inputId = React.useId();
const commit = React.useCallback(
(next: string) => {
setInputValue(next);
setOpen(false);
onCommit?.(next);
},
[onCommit, setInputValue, setOpen],
);
const ctxValue = React.useMemo<AutocompleteContextValue>(
() => ({
commit,
inputId,
inputRef,
inputValue,
open,
openOnFocus,
setInputValue,
setOpen,
}),
[commit, inputId, inputValue, open, openOnFocus, setInputValue, setOpen],
);
// cmdk hard-codes a sr-only `<label cmdk-label for={cmdkUseId}>` inside
// `<Command>` and points its `for` at a useId that cmdk generates for
// *its own* input. Our visible input gets its id from `FormControl`'s
// Radix Slot instead, so the two ids never line up and Chrome flags
// "Incorrect use of <label for=FORM_ELEMENT>". The label can't be
// disabled from cmdk's API. Re-point the `for` at the real input id
// after mount so the HTML-level association is valid (cmdk already
// wires `aria-labelledby` to the same label element through Slot, so
// screen readers stayed correct — this fix is for DevTools/HTML
// validators).
React.useLayoutEffect(() => {
const input = inputRef.current;
const cmdkLabel = input?.closest('[cmdk-root]')?.querySelector<HTMLLabelElement>('label[cmdk-label]');
if (input?.id && cmdkLabel && cmdkLabel.htmlFor !== input.id) {
cmdkLabel.htmlFor = input.id;
}
});
return (
<AutocompleteContext.Provider value={ctxValue}>
<Popover
onOpenChange={setOpen}
open={open}
>
<Command
filter={filter ?? substringFilter}
label="Suggestions"
shouldFilter={shouldFilter}
>
{children}
</Command>
</Popover>
</AutocompleteContext.Provider>
);
}
function AutocompleteContent({
align = 'start',
children,
className,
listClassName,
onFocusOutside,
onInteractOutside,
onOpenAutoFocus,
ref,
sideOffset = 4,
...props
}: AutocompleteContentProps) {
const { inputRef } = useAutocompleteContext();
const contentRef = React.useRef<null | React.ComponentRef<typeof PopoverPrimitive.Content>>(null);
const composedRef = React.useCallback(
(node: null | React.ComponentRef<typeof PopoverPrimitive.Content>) => {
contentRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<typeof node>).current = node;
}
},
[ref],
);
return (
/*
* Render inline — NO `PopoverPrimitive.Portal`. When the autocomplete
* lives inside a Radix `Dialog`, the dialog locks scroll on `body`
* (`body { pointer-events: none }` + a wheel/touch interceptor on
* the document). A portal'd popover ends up on `body`, outside the
* dialog DOM, and inherits the lock — wheel events on the suggestion
* list never fire and the user can't scroll. Rendering inline keeps
* the popover inside the dialog content, where the scroll lock
* doesn't apply, while Radix Popper still positions it correctly
* relative to the anchor (the visible input).
*
* Styles are copied verbatim from `components/ui/popover.tsx` so
* appearance and animations stay consistent with shadcn defaults.
*/
<PopoverPrimitive.Content
align={align}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-(--radix-popover-content-transform-origin) rounded-md border shadow-md outline-hidden',
// Width matches the input (anchor); max-h keeps the popover
// inside the viewport when the suggestion list is large.
'max-h-(--radix-popover-content-available-height) w-(--radix-popover-trigger-width) min-w-(--radix-popover-trigger-width) overflow-hidden p-0',
className,
)}
onFocusOutside={(event) => {
onFocusOutside?.(event);
if (event.defaultPrevented) {
return;
}
// Radix `DismissableLayer` treats the anchor input as
// "outside" (it isn't a Trigger), and its React-tree flag
// races on Content's own focusin when our `CommandList`
// synchronously bounces focus back to the input — both
// produce spurious `focusOutside` events that would
// dismiss the popover. Treat focus on the anchor input
// or anywhere inside Content as "inside".
//
// Radix dispatches this event directly on the focused
// element (`bubbles: false`), so `event.target` IS it.
const focusTarget = event.target as Node | null;
const focusInsideInput = !!(focusTarget && inputRef.current?.contains(focusTarget));
const focusInsideContent = !!(focusTarget && contentRef.current?.contains(focusTarget));
if (focusInsideInput || focusInsideContent) {
event.preventDefault();
}
}}
onInteractOutside={(event) => {
onInteractOutside?.(event);
if (event.defaultPrevented) {
return;
}
// Clicking the input itself shouldn't dismiss the popover —
// Radix treats the anchor as "outside" since it isn't a
// Trigger. The real pointer target is the focused
// element via `event.target` (custom event is
// dispatched on the target, `bubbles: false`).
const interactTarget = event.target as Node | null;
if (interactTarget && inputRef.current?.contains(interactTarget)) {
event.preventDefault();
}
}}
onOpenAutoFocus={(event) => {
onOpenAutoFocus?.(event);
if (event.defaultPrevented) {
return;
}
// Keep focus on the input so typing keeps working immediately.
event.preventDefault();
}}
ref={composedRef}
sideOffset={sideOffset}
{...props}
>
{/*
* Explicit max-height + scroll on the cmdk list. shadcn's
* `CommandList` defaults to `max-h-[300px] overflow-y-auto`, but
* re-applying it here lets consumers override the cap via
* `listClassName` without losing scroll behaviour, and pins the
* list so a long suggestion set scrolls inside the popover
* instead of pushing the popover past the viewport.
*/}
<CommandList
className={cn('max-h-[280px] overflow-x-hidden overflow-y-auto', listClassName)}
onFocus={(event) => {
// cmdk re-focuses the input by id when its value
// changes (`document.getElementById(inputId).focus()`),
// falling back to the list when the lookup fails — and
// it fails as soon as a wrapper like `FormControl`
// overrides the input's id via Radix Slot. The listbox
// then steals focus and typing breaks. Per the
// aria-activedescendant pattern the listbox shouldn't
// hold real focus (tabindex=-1), so bounce it back.
// `target === currentTarget` skips legitimate child focus.
if (event.target === event.currentTarget) {
inputRef.current?.focus();
}
}}
>
{children}
</CommandList>
</PopoverPrimitive.Content>
);
}
function AutocompleteEmpty(props: AutocompleteEmptyProps) {
return <CommandEmpty {...props} />;
}
function AutocompleteGroup(props: AutocompleteGroupProps) {
return <CommandGroup {...props} />;
}
function AutocompleteInput({
className,
id,
onFocus,
onKeyDown,
onPointerDown,
ref: forwardedRef,
...props
}: AutocompleteInputProps & { ref?: React.Ref<HTMLInputElement> }) {
const { commit, inputId, inputRef, inputValue, open, openOnFocus, setInputValue, setOpen } =
useAutocompleteContext();
// True when Enter would land on a real match. Selecting a suggestion
// commits through cmdk's `onSelect`; Enter without a match commits the
// raw input ourselves. Returning a primitive (not an object) lets
// `useSyncExternalStore`'s referential check halve per-keystroke renders.
const hasActiveMatch = useCommandState((state) => state.filtered.count > 0 && Boolean(state.value));
// Suppress the autoFocus-fired `focus` on mount: opening the popover
// before the user did anything is jarring (browser address bars behave
// the same). React's autoFocus fires the native event synchronously
// during `commitMount`, before any effect — so the flag is still `true`
// for that first focus and flipped to `false` here for every
// user-driven focus (click, Tab, re-focus after Escape).
const isMountFocusRef = React.useRef(true);
React.useEffect(() => {
isMountFocusRef.current = false;
}, []);
const setInputRef = React.useCallback(
(node: HTMLInputElement | null) => {
inputRef.current = node;
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (forwardedRef) {
(forwardedRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
}
},
[forwardedRef, inputRef],
);
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
onFocus?.(event);
if (event.defaultPrevented) {
return;
}
if (openOnFocus && !isMountFocusRef.current) {
setOpen(true);
}
};
const handlePointerDown = (event: React.PointerEvent<HTMLInputElement>) => {
onPointerDown?.(event);
if (event.defaultPrevented) {
return;
}
// Without this, clicking an already-focused input (e.g. when the
// dialog opened with autoFocus on the input and the user then
// clicks it) won't fire `focus` again — and the popover would stay
// closed despite a clear user intent. PointerDown is the cleanest
// signal of "user is interacting with the field right now".
if (openOnFocus) {
setOpen(true);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) {
return;
}
if (event.key === 'Escape') {
setOpen(false);
return;
}
if (event.key === 'ArrowDown') {
// Make the dropdown reachable from the keyboard even when it
// didn't auto-open on focus. cmdk's own ArrowDown handler will
// then move the highlight into the list.
setOpen(true);
return;
}
if (event.key === 'Enter' && !hasActiveMatch) {
// Stop cmdk from also handling Enter (which would no-op here
// but keeps the popover open).
event.preventDefault();
event.stopPropagation();
commit(inputValue);
}
};
const handleValueChange = (next: string) => {
setInputValue(next);
// Re-open on typing — handles the case where the user closed the
// popover with Escape but then resumes editing, and is the main
// entry point now that focus alone no longer auto-opens on mount.
setOpen(true);
};
return (
<PopoverAnchor asChild>
{/*
* `asChild` delegates the DOM input (and shadcn styling) to the
* project's `Input` while cmdk keeps value sync, ARIA combobox
* attributes and `cmdk-input=""`. cmdk also hard-codes
* `autoComplete/autoCorrect="off"` and `spellCheck={false}` —
* don't repeat them.
*/}
<CommandPrimitive.Input
asChild
onValueChange={handleValueChange}
value={inputValue}
>
{/*
* `aria-expanded` is hard-coded to `true` inside cmdk —
* the attribute lies whenever the popover is closed
* (after Enter, Escape, outside click). Radix Slot
* merges child props on top of slot props for non-handler
* attributes, so passing `aria-expanded={open}` here
* wins over cmdk's hard-coded value and keeps it in sync
* with the real popover state for screen readers.
*/}
<Input
aria-expanded={open}
className={className}
id={id ?? inputId}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
ref={setInputRef}
{...props}
/>
</CommandPrimitive.Input>
</PopoverAnchor>
);
}
function AutocompleteItem({ onSelect, value, ...props }: AutocompleteItemProps) {
const { commit } = useAutocompleteContext();
return (
<CommandItem
onSelect={(selected) => {
if (onSelect) {
onSelect(selected);
return;
}
commit(selected);
}}
value={value}
{...props}
/>
);
}
function AutocompleteSeparator(props: AutocompleteSeparatorProps) {
return <CommandSeparator {...props} />;
}
export {
Autocomplete,
AutocompleteContent,
AutocompleteEmpty,
AutocompleteGroup,
AutocompleteInput,
AutocompleteItem,
AutocompleteSeparator,
};
+33
View File
@@ -0,0 +1,33 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
className={cn('bg-muted flex h-full w-full items-center justify-center rounded-full', className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
);
}
export { Avatar, AvatarFallback, AvatarImage };
+43
View File
@@ -0,0 +1,43 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2 py-0.5 gap-1 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
defaultVariants: {
variant: 'default',
},
variants: {
variant: {
blue: 'border-blue-500/20 bg-blue-500/10 text-blue-600',
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
green: 'border-green-500/20 bg-green-500/10 text-green-600',
orange: 'border-orange-500/20 bg-orange-500/10 text-orange-600',
outline: 'text-foreground',
pink: 'border-pink-500/20 bg-pink-500/10 text-pink-600',
purple: 'border-purple-500/20 bg-purple-500/10 text-purple-600',
red: 'border-red-500/20 bg-red-500/10 text-red-600',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
yellow: 'border-yellow-500/20 bg-yellow-500/10 text-yellow-600',
},
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+105
View File
@@ -0,0 +1,105 @@
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Breadcrumb({
...props
}: React.ComponentProps<'nav'> & {
separator?: React.ReactNode;
}) {
return (
<nav
aria-label="breadcrumb"
{...props}
/>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
role="presentation"
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word sm:gap-2.5',
className,
)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
aria-current="page"
aria-disabled="true"
className={cn('text-foreground font-normal', className)}
role="link"
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
return (
<li
aria-hidden="true"
className={cn('[&>svg]:h-3.5 [&>svg]:w-3.5', className)}
role="presentation"
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
}
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
};
+60
View File
@@ -0,0 +1,60 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
defaultVariants: {
size: 'default',
variant: 'default',
},
variants: {
size: {
default: 'h-9 px-4 py-2',
icon: 'h-9 w-9',
'icon-lg': 'size-10',
'icon-sm': 'size-8',
'icon-xs': 'size-7',
lg: 'h-10 rounded-md px-8',
sm: 'h-8 rounded-md px-3 text-xs',
xs: 'h-7 rounded-md px-2 text-xs gap-1.5',
},
variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
},
},
},
);
export interface ButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({ asChild = false, className, size, type, variant, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ className, size, variant }))}
// HTML's default `type` for a `<button>` inside a `<form>` is `submit`,
// which silently submits the form on every click — every nav cluster,
// toggle, or dropdown trigger placed inside a form would post the
// form. The library convention (cf. `InputGroupButton`) is to default
// to `"button"` and require explicit `type="submit"` for the rare
// genuine submit buttons. `asChild` consumers control their own
// element, so we only set the default when we render a real button.
type={asChild ? type : (type ?? 'button')}
{...props}
/>
);
}
export { Button, buttonVariants };
+60
View File
@@ -0,0 +1,60 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker, type DayPickerProps } from 'react-day-picker';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export type CalendarProps = DayPickerProps;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
className={cn('p-3', className)}
classNames={{
button_next: cn(
buttonVariants({ variant: 'outline' }),
'absolute top-3 right-3 z-10 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'absolute top-3 left-3 z-10 h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
caption: 'flex justify-center pt-1 pb-2 relative items-center select-none',
caption_label: 'text-sm font-medium select-none',
day: cn(
buttonVariants({ variant: 'ghost' }),
'hover:bg-accent hover:text-accent-foreground h-9 w-9 p-0 font-normal aria-selected:opacity-100',
),
day_button: 'h-9 w-9 p-0 font-normal',
disabled: 'text-muted-foreground opacity-50',
hidden: 'invisible',
month: 'space-y-4',
month_caption: 'flex justify-center pt-1 pb-2 relative items-center',
month_grid: 'w-full border-collapse mt-4',
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
nav: 'flex items-center m-0',
outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
range_end: 'day-range-end',
range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
today: 'bg-accent text-accent-foreground',
week: 'flex w-full mt-2 justify-center',
weekday: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
weekdays: 'flex justify-center',
...classNames,
}}
components={{
Chevron: ({ orientation }) =>
orientation === 'left' ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />,
}}
showOutsideDays={showOutsideDays}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
+59
View File
@@ -0,0 +1,59 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('bg-card text-card-foreground rounded-xl border shadow-sm', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('p-4 pt-0', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex items-center p-4 pt-0', className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'h3'>) {
return (
<h3
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
);
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+23
View File
@@ -0,0 +1,23 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
className={cn(
'peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground grid h-4 w-4 shrink-0 place-content-center rounded-sm border shadow focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
+118
View File
@@ -0,0 +1,118 @@
import { type DialogProps } from '@radix-ui/react-dialog';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Command as CommandPrimitive } from 'cmdk';
import * as React from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
}
function CommandDialog({ children, ...props }: DialogProps) {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="**:[[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
className="text-muted-foreground/50 py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
className={cn(
'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex items-center border-b px-3">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
);
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
className={cn(
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className,
)}
{...props}
/>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
);
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
};
+184
View File
@@ -0,0 +1,184 @@
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
function ContextMenuCheckboxItem({
checked,
children,
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] origin-[--radix-context-menu-content-transform-origin] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Item
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
inset && 'pl-8',
className,
)}
{...props}
/>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
className={cn('text-foreground px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
);
}
function ContextMenuRadioItem({
children,
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-context-menu-content-transform-origin] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
function ContextMenuSubTrigger({
children,
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
);
}
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
};
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More