app: tool page converge button for enabled-but-not-installed drift

The path-tool lifecycle offered only Enable/Disable (keyed on enabled), so an enabled-but-not-on-PATH tool showed just "Disable" with no way to converge it. Add an Install/Uninstall (apply) action whenever desired (enabled) and actual (installed) disagree.
This commit is contained in:
2026-07-07 17:13:52 -07:00
parent 26f6b1d43c
commit 14879a4f7a

View File

@@ -1,10 +1,13 @@
import { useParams, Link } from "react-router-dom" import { useParams, Link } from "react-router-dom"
import { Loader2, Package } from "lucide-react" import { Loader2 } from "lucide-react"
import { useDeployment, useSetEnabled } from "@/services/api/hooks" import { useApply, useDeployment, useSetEnabled } from "@/services/api/hooks"
import { kindIcon } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader" import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel" import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { RelatedDeployments } from "@/components/detail/RelatedDeployments" import { RelatedDeployments } from "@/components/detail/RelatedDeployments"
const ProgramIcon = kindIcon("program")
export function ToolDetailPage() { export function ToolDetailPage() {
const { name } = useParams<{ name: string }>() const { name } = useParams<{ name: string }>()
const { data: deployment, isLoading, error, refetch } = useDeployment(name ?? "") const { data: deployment, isLoading, error, refetch } = useDeployment(name ?? "")
@@ -60,7 +63,7 @@ export function ToolDetailPage() {
to={`/programs/${program}`} to={`/programs/${program}`}
className="inline-flex items-center gap-1.5 text-sm text-[var(--primary)] hover:underline" className="inline-flex items-center gap-1.5 text-sm text-[var(--primary)] hover:underline"
> >
<Package size={14} /> {program} <ProgramIcon size={14} /> {program}
</Link> </Link>
</div> </div>
@@ -83,8 +86,15 @@ function PathLifecycle({
installed: boolean | null installed: boolean | null
onDone: () => void onDone: () => void
}) { }) {
const { mutate, isPending } = useSetEnabled() const setEnabled = useSetEnabled()
const apply = useApply()
const installed = installedState === true const installed = installedState === true
// Drift: the desired state (enabled) and the actual state (installed) disagree —
// config says it should be on PATH but isn't, or vice versa. The enable/disable
// toggle changes *intent*; converging is a separate act, so offer an Apply button
// that reconciles reality to the current intent without flipping it.
const drift = installedState !== null && installedState !== enabled
const busy = setEnabled.isPending || apply.isPending
const dot = const dot =
installedState === true installedState === true
? "bg-green-500" ? "bg-green-500"
@@ -97,20 +107,34 @@ function PathLifecycle({
<span className={`h-2 w-2 rounded-full shrink-0 ${dot}`} /> <span className={`h-2 w-2 rounded-full shrink-0 ${dot}`} />
<span>{installed ? "Installed on PATH" : "Not installed"}</span> <span>{installed ? "Installed on PATH" : "Not installed"}</span>
{!enabled && <span className="text-xs text-amber-400">disabled</span>} {!enabled && <span className="text-xs text-amber-400">disabled</span>}
{drift && <span className="text-xs text-amber-400">needs apply</span>}
<span className="text-xs text-[var(--muted)]">manager: path</span> <span className="text-xs text-[var(--muted)]">manager: path</span>
</div> </div>
<div className="flex items-center gap-2">
{drift && (
<button <button
onClick={() => mutate({ name, enabled: !enabled }, { onSuccess: onDone })} onClick={() => apply.mutate({ name }, { onSuccess: onDone })}
disabled={isPending} disabled={busy}
title={enabled ? "Converge onto PATH" : "Remove from PATH"}
className="flex items-center gap-1.5 px-2.5 py-1 text-sm rounded border border-green-800 text-green-400 hover:bg-green-800/30 transition-colors disabled:opacity-40"
>
{apply.isPending && <Loader2 size={14} className="animate-spin" />}
{enabled ? "Install" : "Uninstall"}
</button>
)}
<button
onClick={() => setEnabled.mutate({ name, enabled: !enabled }, { onSuccess: onDone })}
disabled={busy}
className={`flex items-center gap-1.5 px-2.5 py-1 text-sm rounded border transition-colors disabled:opacity-40 ${ className={`flex items-center gap-1.5 px-2.5 py-1 text-sm rounded border transition-colors disabled:opacity-40 ${
enabled enabled
? "border-red-800 text-red-400 hover:bg-red-800/30" ? "border-red-800 text-red-400 hover:bg-red-800/30"
: "border-green-800 text-green-400 hover:bg-green-800/30" : "border-green-800 text-green-400 hover:bg-green-800/30"
}`} }`}
> >
{isPending && <Loader2 size={14} className="animate-spin" />} {setEnabled.isPending && <Loader2 size={14} className="animate-spin" />}
{enabled ? "Disable" : "Enable"} {enabled ? "Disable" : "Enable"}
</button> </button>
</div> </div>
</div>
) )
} }