Files
fusion/packages/dashboard/app/components/CliBinaryPanel.tsx
gsxdsm 6ea53966f6 FN-7885: migrate remaining direct clipboard callers to shared copyTextToClipboard helper
Replaces the last direct navigator.clipboard.writeText() call sites across dashboard components and the reports plugin with the shared copyTextToClipboard helper, fixing copy actions that crashed or silently failed on non-secure origins (HTTP/mobile).

- Migrated AgentDetailView, AgentErrorDetailsModal, CliBinaryPanel, GitManagerModal, LoginInstructions, PrPanel, SecretsView, and StashConflictModal to use copyTextToClipboard (secure-context guard + execCommand fallback, boolean result handling) instead of calling navigator.clipboard directly.
- Migrated the fusion-plugin-reports ShareBlocksPanel to the same helper and added a vitest alias so the plugin's subpath import resolves to the dashboard's copyToClipboard util instead of collapsing to its package root.
- Added ./app/utils/copyToClipboard subpath export to @fusion/dashboard's package.json.
- Added/extended tests covering copy success, fallback, and failure paths for AgentDetailView, CliBinaryPanel, AgentErrorDetailsModal, GitManagerModal, SecretsView, StashConflictModal, and ShareBlocksPanel.
- Added a patch changeset documenting the fix for @runfusion/fusion.

Files changed:
 .changeset/fn-7885-clipboard-migration.md          |  7 ++
 packages/dashboard/app/components/AgentDetailView.tsx   | 16 ++++-
 packages/dashboard/app/components/AgentErrorDetailsModal.tsx      |  5 +-
 packages/dashboard/app/components/CliBinaryPanel.tsx    | 16 +++--
 packages/dashboard/app/components/GitManagerModal.tsx   | 16 ++++-
 packages/dashboard/app/components/LoginInstructions.tsx | 21 +++---
 packages/dashboard/app/components/PrPanel.tsx      |  9 ++-
 packages/dashboard/app/components/SecretsView.tsx  | 11 ++-
 packages/dashboard/app/components/StashConflictModal.tsx          | 13 ++--
 packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx        | 56 +++++++++++++++
 packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx      | 18 +++++
 packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx         | 68 ++++++++++++++++++
 packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx  | 23 ++++++
 packages/dashboard/app/components/__tests__/SecretsView.test.tsx  | 82 ++++++++++++++++++++++
 packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx          | 25 ++++++-
 packages/dashboard/package.json                    |  4 ++
 plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx  |  5 +-
 plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx | 43 +++++++++++-
 plugins/fusion-plugin-reports/vitest.config.ts     |  2 +
 19 files changed, 402 insertions(+), 38 deletions(-)

Fusion-Task-Id: FN-7885
Fusion-Task-Lineage: 122a54ea-962b-4ae1-98ac-c28e03f4f8ca
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-12 17:54:21 -07:00

217 lines
7.9 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
fetchFnBinaryStatus,
installFnBinary,
type FnBinaryInstallResult,
type FnBinaryStatus,
} from "../api/legacy";
import { copyTextToClipboard } from "../utils/copyToClipboard";
import "./CliBinaryPanel.css";
interface Props {
/**
* When true, the panel is mounted but should not auto-fetch on render.
* Used by the first-launch banner so it can show a button without
* forcing a probe before the user opts in.
*/
defer?: boolean;
}
// Note: These labels are fetched dynamically in the component via useTranslation
// to support i18n. See below for the actual label rendering.
/**
* Settings panel for the `fn` / `fusion` global CLI binary.
*
* Shows current install state, a one-click install button (runs
* `npm install -g runfusion.ai` server-side), and two copy-to-clipboard
* commands so users with non-default npm setups can install themselves.
*/
export function CliBinaryPanel({ defer = false }: Props) {
const { t } = useTranslation("app");
const [status, setStatus] = useState<FnBinaryStatus | null>(null);
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState(false);
const [installResult, setInstallResult] = useState<FnBinaryInstallResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const getStateLabel = (state: FnBinaryStatus["state"]): { text: string; tone: "ok" | "warn" | "err" } => {
switch (state) {
case "installed":
return { text: t("cliBinary.stateInstalled", "Installed"), tone: "ok" };
case "missing":
return { text: t("cliBinary.stateMissing", "Not installed"), tone: "err" };
case "version-mismatch":
return { text: t("cliBinary.stateVersionMismatch", "Version mismatch"), tone: "warn" };
case "skipped":
return { text: t("cliBinary.stateCheckDisabled", "Check disabled"), tone: "warn" };
}
};
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const next = await fetchFnBinaryStatus();
setStatus(next);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (defer) return;
void refresh();
}, [defer, refresh]);
const onInstall = useCallback(async () => {
setInstalling(true);
setInstallResult(null);
setError(null);
try {
const response = await installFnBinary();
setStatus({
binary: response.binary,
expectedVersion: response.expectedVersion,
state: response.state,
install: response.install,
});
setInstallResult(response.installResult);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setInstalling(false);
}
}, []);
/*
FNXC:Clipboard 2026-07-12-00:00:
Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback.
*/
const copy = useCallback(async (label: string, value: string) => {
const copiedToClipboard = await copyTextToClipboard(value);
if (!copiedToClipboard) return;
setCopied(label);
setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500);
}, []);
const stateMeta = status ? getStateLabel(status.state) : null;
return (
<div className="cli-binary-panel">
<div className="cli-binary-header">
<h4 className="settings-section-heading">{t("cliBinary.heading", "CLI Binary")}</h4>
{stateMeta && (
<span className={`cli-binary-pill cli-binary-pill--${stateMeta.tone}`}>
{stateMeta.text}
</span>
)}
</div>
<small className="cli-binary-help">
{t("cliBinary.help", "Installing the global CLI lets you run fn and fusion from any terminal. Automations and scripts work without it via npx, but a global install is faster and more convenient.")}
</small>
{loading && !status && <p className="cli-binary-status-line">{t("cliBinary.checking", "Checking…")}</p>}
{status && (
<div className="cli-binary-detail">
{status.binary.installed ? (
<ul className="cli-binary-info-list">
<li>
<span>{t("cliBinary.binaryLabel", "Binary:")}</span>
<code>{status.binary.binary}</code>
</li>
{status.binary.path && (
<li>
<span>{t("cliBinary.pathLabel", "Path:")}</span>
<code>{status.binary.path}</code>
</li>
)}
<li>
<span>{t("cliBinary.versionLabel", "Version:")}</span>
<code>{status.binary.version ?? "unknown"}</code>
<span className="cli-binary-expected">
{t("cliBinary.expectedVersion", "(expected {{version}})", { version: status.expectedVersion })}
</span>
</li>
</ul>
) : (
<p className="cli-binary-status-line">
{t("cliBinary.notOnPath", "Neither fn nor fusion was found on PATH.")}
</p>
)}
<div className="cli-binary-actions">
<button
type="button"
className="cli-binary-install-btn"
onClick={onInstall}
disabled={installing}
>
{installing
? t("cliBinary.installing", "Installing…")
: status.binary.installed
? t("cliBinary.reinstall", "Reinstall")
: t("cliBinary.installWithNpm", "Install with npm")}
</button>
<button
type="button"
className="cli-binary-refresh-btn"
onClick={() => void refresh()}
disabled={loading || installing}
>
{t("cliBinary.refresh", "Refresh")}
</button>
</div>
<div className="cli-binary-commands">
<label>{t("cliBinary.orCopyLabel", "Or copy and run yourself:")}</label>
{[
{ label: "npm", command: status.install.npm },
{ label: "curl", command: status.install.curl },
].map(({ label, command }) => (
<div key={label} className="cli-binary-command-row">
<code>{command}</code>
<button
type="button"
onClick={() => void copy(label, command)}
className="cli-binary-copy-btn"
>
{copied === label ? t("cliBinary.copied", "Copied") : t("cliBinary.copy", "Copy")}
</button>
</div>
))}
</div>
</div>
)}
{installResult && (
<details className="cli-binary-install-log" open={!installResult.success}>
<summary>
{installResult.success
? t("cliBinary.succeededDuration", "Install succeeded in {{duration}}s", { duration: (installResult.durationMs / 1000).toFixed(1) })
: t("cliBinary.failedExit", "Install failed (exit {{code}})", { code: installResult.exitCode ?? "n/a" })}
</summary>
{installResult.permissionsHint && (
<p className="cli-binary-permissions-hint">{installResult.permissionsHint}</p>
)}
{installResult.stdout && (
<pre className="cli-binary-install-output">{installResult.stdout}</pre>
)}
{installResult.stderr && (
<pre className="cli-binary-install-output cli-binary-install-output--err">
{installResult.stderr}
</pre>
)}
</details>
)}
{error && <p className="field-error">{error}</p>}
</div>
);
}