Files
kandev/apps/web/components/editors/lsp-status-button.tsx
T

473 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState, type ComponentProps, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Progress } from "@kandev/ui/progress";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
IconAlertTriangle,
IconLoader2,
IconPlugConnected,
IconPlugOff,
IconX,
} from "@tabler/icons-react";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import type { LspStatus } from "@/lib/lsp/lsp-client-manager";
import type { LspProgressSnapshot } from "@/lib/lsp/lsp-progress";
import {
getLspConnectionLabel,
getLspLifecycleAction,
getLspProgressView,
} from "@/lib/lsp/lsp-progress-view";
import { getLspUnavailableSetupHint } from "@/lib/lsp/lsp-json-rpc";
const ICON_CLASS = "h-3.5 w-3.5";
const LIFECYCLE_ACTION_KEYS = {
Start: "lsp:startLanguageServer",
Stop: "lsp:stopLanguageServer",
Retry: "lsp:retryLanguageServer",
Stopping: "lsp:stoppingLanguageServer",
} as const;
export function LspStatusIcon({
status,
progress,
}: {
status: LspStatus;
progress: LspProgressSnapshot;
}): ReactNode {
if (progress.active.length > 0 || progress.initializingSince !== null) {
return <IconLoader2 className={`${ICON_CLASS} animate-spin text-blue-500`} aria-hidden />;
}
switch (status.state) {
case "connecting":
case "reconnecting":
case "starting":
case "stopping":
return (
<IconLoader2 className={`${ICON_CLASS} animate-spin text-muted-foreground`} aria-hidden />
);
case "installing":
return <IconLoader2 className={`${ICON_CLASS} animate-spin text-amber-500`} aria-hidden />;
case "ready":
return <IconPlugConnected className={`${ICON_CLASS} text-emerald-500`} aria-hidden />;
case "error":
return <IconAlertTriangle className={`${ICON_CLASS} text-yellow-500`} aria-hidden />;
case "disabled":
case "unavailable":
return <IconPlugOff className={`${ICON_CLASS} text-muted-foreground`} aria-hidden />;
}
}
export function useLspLiveNow(enabled: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!enabled) return;
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [enabled]);
return Math.max(now, Date.now());
}
function ConnectionDetails({
status,
lspLanguage,
progress,
}: {
status: LspStatus;
lspLanguage: string;
progress: LspProgressSnapshot;
}) {
const { t } = useTranslation();
const reason = "reason" in status ? status.reason : null;
const setupHint = getLspUnavailableSetupHint(status, lspLanguage);
return (
<section aria-labelledby="lsp-connection-heading" className="space-y-1.5">
<p
id="lsp-connection-heading"
className="text-[0.625rem] font-medium uppercase tracking-wider text-muted-foreground"
>
{t("lsp:connection")}
</p>
<div className="flex items-center gap-2">
<LspStatusIcon status={status} progress={progress} />
<span className="font-medium">{getLspConnectionLabel(status, progress)}</span>
<span className="ml-auto font-mono text-[0.625rem] text-muted-foreground">
{lspLanguage}
</span>
</div>
{reason ? <p className="text-pretty text-muted-foreground">{reason}</p> : null}
{setupHint ? <p className="text-pretty text-muted-foreground">{setupHint}</p> : null}
</section>
);
}
function ActiveProgress({
view,
}: {
view: Extract<ReturnType<typeof getLspProgressView>, { kind: "active" }>;
}) {
const { t } = useTranslation();
return (
<>
<div className="flex items-start gap-2">
<IconLoader2
className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-blue-500"
aria-hidden
/>
<div className="min-w-0 space-y-1">
<p className="text-pretty font-medium">{view.title}</p>
{view.message ? (
<p className="text-pretty text-muted-foreground">{view.message}</p>
) : null}
</div>
</div>
{view.percentage !== null ? (
<div className="flex items-center gap-2">
<Progress
value={view.percentage}
aria-label={t("lsp:progressPercentage", {
title: view.title,
percentage: view.percentage,
})}
data-testid="lsp-work-progress-bar"
className="flex-1"
/>
<span className="w-8 text-right font-mono text-[0.625rem] tabular-nums">
{view.percentage}%
</span>
</div>
) : (
<p className="text-muted-foreground">{t("lsp:noPercentage")}</p>
)}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground">
<span className="tabular-nums">{t("lsp:elapsed", { elapsed: view.elapsed })}</span>
{view.concurrentCount > 1 ? (
<span>{t("lsp:activeWorkItems", { count: view.concurrentCount })}</span>
) : null}
</div>
<p className="flex gap-1.5 text-pretty text-amber-700 dark:text-amber-300">
<IconAlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
{t("lsp:crossFileDefinitionsMayBeIncomplete")}
</p>
</>
);
}
function ProjectProgress({
status,
progress,
lspLanguage,
}: {
status: LspStatus;
progress: LspProgressSnapshot;
lspLanguage: string;
}) {
const { t } = useTranslation();
const tracked = progress.active[0]?.startedAt ?? progress.initializingSince;
const now = useLspLiveNow(tracked !== null);
const view = getLspProgressView(status, progress, now, lspLanguage);
return (
<section
aria-labelledby="lsp-project-progress-heading"
className="min-w-0 space-y-2 [overflow-wrap:anywhere]"
data-testid="lsp-project-progress"
data-lsp-progress-kind={view.kind}
data-lsp-initialization-stage={view.kind === "initializing" ? view.stage : undefined}
aria-live="polite"
>
<p
id="lsp-project-progress-heading"
className="text-[0.625rem] font-medium uppercase tracking-wider text-muted-foreground"
>
{t("lsp:projectAnalysis")}
</p>
{view.kind === "active" ? <ActiveProgress view={view} /> : null}
{view.kind === "initializing" ? (
<>
<div className="flex items-start gap-2">
{view.stage === "long_running" ? (
<IconAlertTriangle
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500"
aria-hidden
/>
) : (
<IconLoader2
className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-blue-500"
aria-hidden
/>
)}
<div className="space-y-1">
<p className="font-medium">{view.title}</p>
<p className="text-pretty text-muted-foreground">{view.description}</p>
</div>
</div>
<p className="text-muted-foreground tabular-nums">
{t("lsp:elapsed", { elapsed: view.elapsed })}
</p>
<p
className={
view.stage === "long_running"
? "text-pretty text-amber-700 dark:text-amber-300"
: "text-pretty text-muted-foreground"
}
>
{view.guidance}
</p>
</>
) : null}
{view.kind === "completed" ? (
<>
<p className="font-medium">{view.title}</p>
<p className="text-pretty text-muted-foreground">
{view.workTitle}
{view.message ? ` - ${view.message}` : ""}
</p>
<p className="text-pretty text-muted-foreground">{t("lsp:reportedWorkDisclaimer")}</p>
</>
) : null}
{view.kind === "idle" || view.kind === "waiting" ? (
<>
<p className="font-medium">{view.title}</p>
<p className="text-pretty text-muted-foreground">{view.description}</p>
</>
) : null}
</section>
);
}
export function LspProgressDetails({
status,
progress,
lspLanguage,
onToggle,
touch,
}: {
status: LspStatus;
progress: LspProgressSnapshot;
lspLanguage: string;
onToggle: () => void;
touch: boolean;
}) {
const { t } = useTranslation();
const action = getLspLifecycleAction(status);
return (
<div className="space-y-3" data-testid="lsp-progress-details">
<ConnectionDetails status={status} lspLanguage={lspLanguage} progress={progress} />
<div className="border-t" />
<ProjectProgress status={status} progress={progress} lspLanguage={lspLanguage} />
<Button
type="button"
variant={action.label === "Stop" ? "outline" : "default"}
className={touch ? "h-11 w-full" : "w-full"}
onClick={onToggle}
disabled={!action.enabled}
data-testid="lsp-lifecycle-action"
data-lsp-action={action.label.toLowerCase()}
>
{t(LIFECYCLE_ACTION_KEYS[action.label])}
</Button>
</div>
);
}
type StatusTriggerProps = {
status: LspStatus;
progress: LspProgressSnapshot;
lspLanguage: string;
open: boolean;
touch: boolean;
} & Omit<ComponentProps<typeof Button>, "children">;
function StatusTrigger({
status,
progress,
lspLanguage,
open,
touch,
...triggerProps
}: StatusTriggerProps) {
const { t } = useTranslation();
const label = t("lsp:languageServerOpenStatus", {
summary: getLspConnectionLabel(status, progress),
});
return (
<Button
type="button"
variant="ghost"
size="icon-sm"
className={touch ? "h-11 w-11" : "h-8 w-8"}
data-testid="lsp-status-button"
data-lsp-state={status.state}
data-lsp-language={lspLanguage}
data-lsp-progress-active={
progress.active.length > 0 || progress.initializingSince !== null ? "true" : "false"
}
aria-label={label}
aria-haspopup="dialog"
aria-expanded={open}
{...triggerProps}
>
<LspStatusIcon status={status} progress={progress} />
</Button>
);
}
type LspStatusButtonProps = {
status: LspStatus;
progress: LspProgressSnapshot;
lspLanguage: string | null;
onToggle: () => void;
};
export type LspStatusDetailsProps = {
status: LspStatus;
progress: LspProgressSnapshot;
lspLanguage: string;
onToggle: () => void;
};
export function LspStatusPopoverContent({
status,
progress,
lspLanguage,
onToggle,
align = "end",
side,
}: LspStatusDetailsProps & Pick<ComponentProps<typeof PopoverContent>, "align" | "side">) {
const { t } = useTranslation();
return (
<PopoverContent
align={align}
side={side}
sideOffset={8}
className="w-80 gap-0 p-0"
data-testid="lsp-status-popover"
aria-label={t("lsp:languageServerStatus")}
>
<div className="border-b px-3 py-2.5">
<p className="font-medium">{t("lsp:languageServer")}</p>
<p className="text-muted-foreground">{t("lsp:connectionAndProjectAnalysis")}</p>
</div>
<div className="p-3">
<LspProgressDetails
status={status}
progress={progress}
lspLanguage={lspLanguage}
onToggle={onToggle}
touch={false}
/>
</div>
</PopoverContent>
);
}
function LspStatusPopover({
status,
progress,
lspLanguage,
onToggle,
}: LspStatusButtonProps & { lspLanguage: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const tooltip = t("lsp:languageServerConnection", {
summary: getLspConnectionLabel(status, progress),
});
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip open={open ? false : undefined}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<StatusTrigger
status={status}
progress={progress}
lspLanguage={lspLanguage}
open={open}
touch={false}
/>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
<LspStatusPopoverContent
status={status}
progress={progress}
lspLanguage={lspLanguage}
onToggle={onToggle}
/>
</Popover>
);
}
function LspStatusDrawer({
status,
progress,
lspLanguage,
onToggle,
}: LspStatusButtonProps & { lspLanguage: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<StatusTrigger
status={status}
progress={progress}
lspLanguage={lspLanguage}
open={open}
touch
onClick={() => setOpen(true)}
/>
<DrawerContent
data-testid="lsp-status-drawer"
className="max-h-[80dvh] flex flex-col overflow-hidden"
>
<DrawerHeader className="flex flex-row items-center justify-between border-b py-2">
<div className="min-w-0 text-left">
<DrawerTitle>{t("lsp:languageServer")}</DrawerTitle>
<DrawerDescription>{t("lsp:connectionAndProjectAnalysis")}</DrawerDescription>
</div>
<DrawerClose asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-11 w-11"
aria-label={t("lsp:closeLanguageServerStatus")}
data-testid="lsp-status-drawer-close"
>
<IconX className="h-4 w-4" />
</Button>
</DrawerClose>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-4" data-vaul-no-drag>
<LspProgressDetails
status={status}
progress={progress}
lspLanguage={lspLanguage}
onToggle={onToggle}
touch
/>
</div>
</DrawerContent>
</Drawer>
);
}
export function LspStatusButton(props: LspStatusButtonProps) {
const useDrawer = useTouchDrawer();
if (!props.lspLanguage) return null;
if (useDrawer) return <LspStatusDrawer {...props} lspLanguage={props.lspLanguage} />;
return <LspStatusPopover {...props} lspLanguage={props.lspLanguage} />;
}