Files
fusion/packages/dashboard/app/components/CliBinaryInstallBanner.tsx
gsxdsm 1e49494bac feat(i18n): full-sweep string migration — 5,930 keys across 5 locales (#1352)
Migration (multi-agent sweep over 216 files, 60 batches):
- Every user-visible dashboard + TUI string moved to t() with the exact
  English inline default (en rendering byte-identical)
- Catalogs merged from per-batch fragments: en/zh-CN/zh-TW/fr/es now
  carry ~5,930 keys each across common/app/errors/cli namespaces;
  CLI bundles regenerated (6 locales incl. ko)

Integration fixes:
- 18 type errors: reserved {{count}} interpolations renamed, malformed
  plural call, hand-rolled t-param types replaced with TFunction<"app">
- 23 lint errors: superseded label constants/helpers removed
- ExecutorStatusBar hook-order violation (keyboard-open early return
  moved below hooks)
- TUI tests wrapped in I18nextProvider (uninitialized fallback renders
  literal {{placeholders}}); dashboard vitest.setup boots a minimal en
  i18next instance for the same reason

Known WIP (next commits): ~457 residual strings across 50 batches,
Korean drafts for swept keys, and a dashboard test-suite pass that is
still being stabilized (~283 failures under investigation — fake-timer
waitFor interaction, likely stale node_modules vs merged lockfile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:06:53 -07:00

163 lines
5.4 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import {
fetchFnBinaryStatus,
installFnBinary,
type FnBinaryStatus,
} from "../api/legacy";
import "./CliBinaryInstallBanner.css";
interface Props {
/** Open Settings → General so the user can manage manually. */
onOpenSettings: () => void;
}
/** localStorage key for permanent dismissal. */
const DISMISS_KEY = "fusion:cli-binary-banner-dismissed";
function isDismissed(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
}
function persistDismissal(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(DISMISS_KEY, "1");
} catch {
// Ignore quota / private-mode errors — dismissal lasts the session only.
}
}
/**
* One-time banner that nudges users to install the global `fn`/`fusion`
* CLI binary. Renders only when:
*
* - Status probe completes successfully
* - The binary is not on PATH
* - User has not previously dismissed the banner
*
* Dismissal is permanent (localStorage). The Settings → General → CLI
* Binary panel always lets the user reinstall later.
*/
export function CliBinaryInstallBanner({ onOpenSettings }: Props) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
const [dismissed, setDismissed] = useState<boolean>(() => isDismissed());
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
useEffect(() => {
if (dismissed) return;
let cancelled = false;
void fetchFnBinaryStatus()
.then((next) => {
if (!cancelled) setStatus(next);
})
.catch(() => {
// Treat probe failure as "don't show banner" — better silent than
// bothering the user with infrastructure errors on first load.
});
return () => {
cancelled = true;
};
}, [dismissed]);
const handleInstall = useCallback(async () => {
setInstalling(true);
setInstallError(null);
try {
const response = await installFnBinary();
setStatus({
binary: response.binary,
expectedVersion: response.expectedVersion,
state: response.state,
install: response.install,
});
if (!response.installResult.success) {
setInstallError(
response.installResult.permissionsHint ||
response.installResult.stderr ||
`Install failed (exit ${response.installResult.exitCode ?? "n/a"})`,
);
}
} catch (err) {
setInstallError(err instanceof Error ? err.message : String(err));
} finally {
setInstalling(false);
}
}, []);
const handleDismiss = useCallback(() => {
persistDismissal();
setDismissed(true);
}, []);
if (dismissed) return null;
if (!status) return null;
if (status.state === "installed") return null;
// Honour the global `fnBinaryCheckEnabled` opt-out — when checks are
// disabled the install banner would be misleading.
if (status.state === "skipped") return null;
const isMismatch = status.state === "version-mismatch";
const installedVersion = status.binary.version;
const targetVersion = status.expectedVersion;
const title = isMismatch ? t("cli.updateTitle", "Update the Fusion CLI") : t("cli.installTitle", "Install the Fusion CLI");
const body = isMismatch ? (
<>
{t("cli.versionMismatchPrefix", "Your installed")} <code>fn</code>/<code>fusion</code> CLI is{" "}
<strong>v{installedVersion ?? "unknown"}</strong> {t("cli.versionMismatchInfix", "but this dashboard expects")} {" "}
<strong>v{targetVersion}</strong>. {t("cli.versionMismatchSuffix", "Update to stay in sync.")}
</>
) : (
<>
{t("cli.installBody", "Get the {{fn}} and {{fusion}} commands on your terminal so you can drive Fusion from anywhere. One click below or copy the command into your shell.", { fn: "fn", fusion: "fusion" })}
</>
);
const idleLabel = isMismatch ? t("cli.updateButton", "Update with npm") : t("cli.installButton", "Install with npm");
const busyLabel = isMismatch ? t("cli.updating", "Updating…") : t("cli.installing", "Installing…");
return (
<div className="cli-binary-banner" role="status">
<div className="cli-binary-banner__body">
<div className="cli-binary-banner__title">{title}</div>
<div className="cli-binary-banner__text">{body}</div>
<div className="cli-binary-banner__actions">
<button
type="button"
className="cli-binary-banner__primary"
onClick={() => void handleInstall()}
disabled={installing}
>
{installing ? busyLabel : idleLabel}
</button>
<button
type="button"
className="cli-binary-banner__secondary"
onClick={onOpenSettings}
>
{t("cli.openSettings", "Open Settings")}
</button>
</div>
{installError && (
<div className="cli-binary-banner__error">{installError}</div>
)}
</div>
<button
type="button"
className="cli-binary-banner__dismiss"
aria-label={t("actions.dismiss", "Dismiss")}
onClick={handleDismiss}
>
<X size={16} />
</button>
</div>
);
}