Files
kandev/apps/web/lib/lsp/lsp-progress-view.ts
T

221 lines
6.3 KiB
TypeScript

import { t } from "@/lib/i18n";
import type { LspStatus } from "./lsp-json-rpc";
import type { LspProgressSnapshot } from "./lsp-progress";
export type LspLifecycleAction = {
label: "Start" | "Stop" | "Retry" | "Stopping";
enabled: boolean;
};
export const LSP_LONG_INITIALIZATION_MS = 60_000;
function formatElapsedUnit(
unit: "Seconds" | "Minutes" | "Hours",
count: number,
value: number | string = count,
): string {
switch (unit) {
case "Seconds":
return t("lsp:elapsedSeconds", { count, value });
case "Minutes":
return t("lsp:elapsedMinutes", { count, value });
case "Hours":
return t("lsp:elapsedHours", { count, value });
}
}
function joinElapsedParts(major: string, minor: string): string {
return t("lsp:elapsedDurationParts", { major, minor });
}
export type LspProgressView =
| {
kind: "active";
title: string;
message: string | null;
percentage: number | null;
elapsed: string;
concurrentCount: number;
}
| {
kind: "initializing";
stage: "initialize_pending" | "long_running";
title: string;
description: string;
guidance: string;
elapsed: string;
}
| {
kind: "completed";
title: string;
workTitle: string;
message: string | null;
}
| { kind: "idle" | "waiting"; title: string; description: string };
export function formatLspElapsed(elapsedMs: number): string {
const seconds = Math.max(0, Math.floor(elapsedMs / 1_000));
if (seconds < 60) return formatElapsedUnit("Seconds", seconds);
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return joinElapsedParts(
formatElapsedUnit("Minutes", minutes),
formatElapsedUnit("Seconds", seconds % 60, String(seconds % 60).padStart(2, "0")),
);
}
const hours = Math.floor(minutes / 60);
return joinElapsedParts(
formatElapsedUnit("Hours", hours),
formatElapsedUnit("Minutes", minutes % 60, String(minutes % 60).padStart(2, "0")),
);
}
export function getLspConnectionLabel(status: LspStatus, progress?: LspProgressSnapshot): string {
switch (status.state) {
case "disabled":
return t("lsp:off");
case "connecting":
return t("lsp:connecting");
case "reconnecting":
return t("lsp:reconnecting");
case "installing":
return t("lsp:installingLanguageServer");
case "starting":
return progress?.initializingSince !== null && progress?.initializingSince !== undefined
? t("lsp:serverProcessStarted")
: t("lsp:startingLanguageServer");
case "ready":
return t("lsp:connected");
case "stopping":
return t("lsp:stopping");
case "unavailable":
return t("lsp:unavailable");
case "error":
return t("lsp:error");
}
}
export function getLspCompactSummary(
status: LspStatus,
snapshot: LspProgressSnapshot,
now: number,
): string {
const active = snapshot.active[0];
if (active) {
const suffix =
active.percentage === null
? formatLspElapsed(now - active.startedAt)
: `${active.percentage}%`;
return `${active.title} · ${suffix}`;
}
if (snapshot.initializingSince !== null) {
return `${getLspConnectionLabel(status, snapshot)} · ${formatLspElapsed(
now - snapshot.initializingSince,
)}`;
}
return getLspConnectionLabel(status, snapshot);
}
// i18n-exempt: `label` is a discriminant, not copy. `lsp-status-button.tsx`
// uses it as a `LIFECYCLE_ACTION_KEYS` lookup key, compares it with `===` to
// pick the button variant, and lowercases it into `data-lsp-action`.
export function getLspLifecycleAction(status: LspStatus): LspLifecycleAction {
switch (status.state) {
case "disabled":
return { label: "Start", enabled: true };
case "unavailable":
case "error":
return { label: "Retry", enabled: true };
case "stopping":
return { label: "Stopping", enabled: false };
default:
return { label: "Stop", enabled: true };
}
}
function getInitializationGuidance(longRunning: boolean, language?: string): string {
if (!longRunning) return t("lsp:crossFileFeaturesAfterInitialization");
if (language?.toLowerCase() === "kotlin") {
return t("lsp:kotlinInitializationGuidance");
}
return t("lsp:crossFileFeaturesUnavailable");
}
export function getLspProgressView(
status: LspStatus,
snapshot: LspProgressSnapshot,
now: number,
language?: string,
): LspProgressView {
const active = snapshot.active[0];
if (active) {
return {
kind: "active",
title: active.title,
message: active.message,
percentage: active.percentage,
elapsed: formatLspElapsed(now - active.startedAt),
concurrentCount: snapshot.active.length,
};
}
if (snapshot.initializingSince !== null) {
const elapsedMs = Math.max(0, now - snapshot.initializingSince);
const longRunning = elapsedMs >= LSP_LONG_INITIALIZATION_MS;
return {
kind: "initializing",
stage: longRunning ? "long_running" : "initialize_pending",
title: longRunning ? t("lsp:initializationLongRunning") : t("lsp:serverProcessStarted"),
description: longRunning
? t("lsp:serverStillInitializing")
: t("lsp:waitingForInitializeResponse"),
guidance: getInitializationGuidance(longRunning, language),
elapsed: formatLspElapsed(elapsedMs),
};
}
if (snapshot.completed) {
return {
kind: "completed",
title: t("lsp:serverWorkFinished"),
workTitle: snapshot.completed.title,
message: snapshot.completed.message,
};
}
if (status.state === "ready") {
return {
kind: "idle",
title: t("lsp:noBackgroundWorkReported"),
description: t("lsp:noBackgroundWorkReportedDescription"),
};
}
if (status.state === "disabled") {
return {
kind: "waiting",
title: t("lsp:languageServerIsOff"),
description: t("lsp:startServerForProgress"),
};
}
if (status.state === "error" || status.state === "unavailable") {
return {
kind: "waiting",
title: t("lsp:progressUnavailable"),
description: t("lsp:progressUnavailableDescription"),
};
}
return {
kind: "waiting",
title: t("lsp:waitingForLanguageServer"),
description:
status.state === "stopping"
? t("lsp:progressWillClear")
: t("lsp:progressAppearsDuringInitialization"),
};
}