import { TriangleAlertIcon } from "lucide-react";import { useCallback, useEffect, useState } from "react";import { isElectron } from "../../env";import { useMediaQuery } from "../../hooks/useMediaQuery";import { cn } from "../../lib/utils";import { ensureLocalApi } from "../../localApi";import { useDesktopUpdateState } from "../../state/desktopUpdate";import { stackedThreadToast, toastManager } from "../ui/toast";import { canCheckForUpdate, getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult,} from "../desktopUpdate.logic";import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";import { Alert, AlertDescription, AlertTitle } from "../ui/alert";import { Separator } from "../ui/separator";import { SidebarMenuItem } from "../ui/sidebar";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { DesktopUpdateStatusIcon, shouldContinueDesktopUpdateCheckAnimation, shouldShowDesktopUpdateCheckIcon,} from "./DesktopUpdateStatusIcon"; function resolveSidebarUpdatePresentation({ action, isDownloading, showCheckIcon,}: { readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>; readonly isDownloading: boolean; readonly showCheckIcon: boolean;}) { const showUpdateDetails = action !== "none" || isDownloading; const iconStatus = showCheckIcon ? "checking" : action === "install" ? "downloaded" : isDownloading ? "downloading" : action === "download" ? "available" : "idle"; return { iconStatus, showUpdateDetails, showUpdateIconState: showUpdateDetails && !showCheckIcon, } as const;} function keyReleaseNoteItems(items: ReadonlyArray<string>) { const occurrences = new Map<string, number>(); return items.map((item) => { const occurrence = occurrences.get(item) ?? 0; occurrences.set(item, occurrence + 1); return { item, key: JSON.stringify([item, occurrence]) }; });} function SidebarUpdateReleaseNotesTooltip({ state, tooltip,}: { readonly state: NonNullable<ReturnType<typeof useDesktopUpdateState>>; readonly tooltip: string;}) { if (state.channel !== "nightly" || state.releaseNotes.length === 0) { return <>{tooltip}</>; } return ( <div className="w-fit max-w-[min(24rem,calc(100vw-2rem))] text-left"> <div className="px-1"> {state.status === "available" ? ( <div> <div className="whitespace-nowrap text-sm leading-5 font-medium"> Update ready to download </div> {state.availableVersion ? ( <div className="mt-0.5 text-xs leading-4 text-update-foreground"> {state.availableVersion} </div> ) : null} </div> ) : ( <div className="text-sm leading-5 font-medium">{tooltip}</div> )} </div> <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1"> {state.releaseNotes.map((releaseNote, index) => ( <div key={releaseNote.version}> {index > 0 && <Separator className="my-3 bg-border/60" />} <section> <h3 className="text-foreground text-xs leading-4 font-semibold"> {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} </h3> <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90"> {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( <li className="list-disc break-words" key={key}> {item} </li> ))} </ul> </section> </div> ))} </div> </div> );} export function SidebarUpdateArchitectureWarning() { return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null;} function SidebarUpdateArchitectureWarningContent() { const state = useDesktopUpdateState(); const visible = shouldShowArm64IntelBuildWarning(state); const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null; if (!visible || !description) return null; return ( <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs"> <TriangleAlertIcon /> <AlertTitle>Intel build on Apple Silicon</AlertTitle> <AlertDescription>{description}</AlertDescription> </Alert> );} export function SidebarUpdatePill() { return isElectron ? <SidebarUpdateControl /> : null;} function SidebarUpdateControl() { const state = useDesktopUpdateState(); const [isActionPending, setIsActionPending] = useState(false); const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); useEffect(() => { if (prefersReducedMotion) { setIsCheckAnimationLatched(false); } else if (state?.status === "checking") { setIsCheckAnimationLatched(true); } }, [prefersReducedMotion, state?.status]); const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const isDownloading = state?.status === "downloading"; const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ isAnimationLatched: isCheckAnimationLatched, isChecking: state?.status === "checking", prefersReducedMotion, }); const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ action, isDownloading, showCheckIcon, }); const tooltip = showUpdateDetails ? state ? getDesktopUpdateButtonTooltip(state) : "Update available" : showCheckIcon ? "Checking for updates…" : "Check for updates"; const disabled = showCheckIcon ? true : showUpdateDetails ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state); const isInteractionDisabled = disabled || isActionPending; const handleAction = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge || !state) return; if (isInteractionDisabled) return; setIsActionPending(true); if (action === "download") { void bridge .downloadUpdate() .then((result) => { if (result.completed) { showDesktopUpdateDownloadedToast(bridge, result.state); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; toastManager.add( stackedThreadToast({ type: "error", title: "Could not download update", description: actionError, }), ); }) .catch((error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not start update download", description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); }) .finally(() => setIsActionPending(false)); return; } if (action === "install") { let confirmed = false; try { confirmed = await ensureLocalApi().dialogs.confirm( getDesktopUpdateInstallConfirmationMessage(state), ); } catch (error) { setIsActionPending(false); toastManager.add( stackedThreadToast({ type: "error", title: "Could not confirm update", description: error instanceof Error ? error.message : "Update confirmation failed.", }), ); return; } if (!confirmed) { setIsActionPending(false); return; } void bridge .installUpdate() .then((result) => { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; toastManager.add( stackedThreadToast({ type: "error", title: "Could not install update", description: actionError, }), ); }) .catch((error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not install update", description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); }) .finally(() => setIsActionPending(false)); return; } if (!prefersReducedMotion) { setIsCheckAnimationLatched(true); setCheckAnimationKey((key) => key + 1); } void bridge .checkForUpdate() .then((result) => { if (result.checked) return; toastManager.add( stackedThreadToast({ type: "error", title: "Could not check for updates", description: result.state.message ?? "Automatic updates are not available in this build.", }), ); }) .catch((error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not check for updates", description: error instanceof Error ? error.message : "Update check failed.", }), ); }) .finally(() => setIsActionPending(false)); }, [action, isInteractionDisabled, prefersReducedMotion, state]); const handleCheckAnimationIteration = useCallback(() => { setIsCheckAnimationLatched( shouldContinueDesktopUpdateCheckAnimation({ isChecking: state?.status === "checking", prefersReducedMotion, }), ); }, [prefersReducedMotion, state?.status]); return ( <SidebarMenuItem className="ml-auto shrink-0"> <Tooltip> <TooltipTrigger render={ <button type="button" aria-label={tooltip} aria-disabled={isInteractionDisabled || undefined} className={cn( "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2", isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer", showUpdateIconState ? cn( "bg-update-surface text-update-foreground", !isInteractionDisabled && "hover:bg-update/12", ) : cn( "text-[var(--sidebar-icon-color)]", !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", ), disabled && !showUpdateIconState && "opacity-60", )} onClick={handleAction} > <DesktopUpdateStatusIcon key={showCheckIcon ? checkAnimationKey : iconStatus} downloadPercent={state?.downloadPercent ?? null} isCheckAnimating={showCheckIcon && !prefersReducedMotion} onCheckAnimationIteration={handleCheckAnimationIteration} status={iconStatus} /> </button> } /> <TooltipPopup align="center" className={ showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0 ? // pointer-events-auto overrides the positioner's pointer-events-none so the // release notes stay open (and scrollable) when the cursor moves into them. "pointer-events-auto max-w-none text-balance" : undefined } side="top" style={ showUpdateDetails ? { background: "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", borderColor: "var(--update-foreground)", } : undefined } variant={showUpdateDetails ? "glass" : "default"} > {showUpdateDetails && state ? ( <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} /> ) : ( tooltip )} </TooltipPopup> </Tooltip> </SidebarMenuItem> );}
import { TriangleAlertIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { isElectron } from "../../env";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { cn } from "../../lib/utils";
import { ensureLocalApi } from "../../localApi";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
import { stackedThreadToast, toastManager } from "../ui/toast";
import {
canCheckForUpdate,
getArm64IntelBuildWarningDescription,
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
getDesktopUpdateInstallConfirmationMessage,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldShowArm64IntelBuildWarning,
shouldToastDesktopUpdateActionResult,
} from "../desktopUpdate.logic";
import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Separator } from "../ui/separator";
import { SidebarMenuItem } from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
DesktopUpdateStatusIcon,
shouldContinueDesktopUpdateCheckAnimation,
shouldShowDesktopUpdateCheckIcon,
} from "./DesktopUpdateStatusIcon";
function resolveSidebarUpdatePresentation({
action,
isDownloading,
showCheckIcon,
}: {
readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;
readonly isDownloading: boolean;
readonly showCheckIcon: boolean;
}) {
const showUpdateDetails = action !== "none" || isDownloading;
const iconStatus = showCheckIcon
? "checking"
: action === "install"
? "downloaded"
: isDownloading
? "downloading"
: action === "download"
? "available"
: "idle";
return {
iconStatus,
showUpdateDetails,
showUpdateIconState: showUpdateDetails && !showCheckIcon,
} as const;
}
function keyReleaseNoteItems(items: ReadonlyArray<string>) {
const occurrences = new Map<string, number>();
return items.map((item) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return { item, key: JSON.stringify([item, occurrence]) };
});
}
function SidebarUpdateReleaseNotesTooltip({
state,
tooltip,
}: {
readonly state: NonNullable<ReturnType<typeof useDesktopUpdateState>>;
readonly tooltip: string;
}) {
if (state.channel !== "nightly" || state.releaseNotes.length === 0) {
return <>{tooltip}</>;
}
return (
<div className="w-fit max-w-[min(24rem,calc(100vw-2rem))] text-left">
<div className="px-1">
{state.status === "available" ? (
<div>
<div className="whitespace-nowrap text-sm leading-5 font-medium">
Update ready to download
</div>
{state.availableVersion ? (
<div className="mt-0.5 text-xs leading-4 text-update-foreground">
{state.availableVersion}
</div>
) : null}
</div>
) : (
<div className="text-sm leading-5 font-medium">{tooltip}</div>
)}
</div>
<div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1">
{state.releaseNotes.map((releaseNote, index) => (
<div key={releaseNote.version}>
{index > 0 && <Separator className="my-3 bg-border/60" />}
<section>
<h3 className="text-foreground text-xs leading-4 font-semibold">
{index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}
</h3>
<ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90">
{keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => (
<li className="list-disc break-words" key={key}>
{item}
</li>
))}
</ul>
</section>
</div>
))}
</div>
</div>
);
}
export function SidebarUpdateArchitectureWarning() {
return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null;
}
function SidebarUpdateArchitectureWarningContent() {
const state = useDesktopUpdateState();
const visible = shouldShowArm64IntelBuildWarning(state);
const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null;
if (!visible || !description) return null;
return (
<Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs">
<TriangleAlertIcon />
<AlertTitle>Intel build on Apple Silicon</AlertTitle>
<AlertDescription>{description}</AlertDescription>
</Alert>
);
}
export function SidebarUpdatePill() {
return isElectron ? <SidebarUpdateControl /> : null;
}
function SidebarUpdateControl() {
const state = useDesktopUpdateState();
const [isActionPending, setIsActionPending] = useState(false);
const [checkAnimationKey, setCheckAnimationKey] = useState(0);
const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
useEffect(() => {
if (prefersReducedMotion) {
setIsCheckAnimationLatched(false);
} else if (state?.status === "checking") {
setIsCheckAnimationLatched(true);
}
}, [prefersReducedMotion, state?.status]);
const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
const isDownloading = state?.status === "downloading";
const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
isAnimationLatched: isCheckAnimationLatched,
isChecking: state?.status === "checking",
prefersReducedMotion,
});
const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
action,
isDownloading,
showCheckIcon,
});
const tooltip = showUpdateDetails
? state
? getDesktopUpdateButtonTooltip(state)
: "Update available"
: showCheckIcon
? "Checking for updates…"
: "Check for updates";
const disabled = showCheckIcon
? true
: showUpdateDetails
? isDesktopUpdateButtonDisabled(state)
: !canCheckForUpdate(state);
const isInteractionDisabled = disabled || isActionPending;
const handleAction = useCallback(async () => {
const bridge = window.desktopBridge;
if (!bridge || !state) return;
if (isInteractionDisabled) return;
setIsActionPending(true);
if (action === "download") {
void bridge
.downloadUpdate()
.then((result) => {
if (result.completed) {
showDesktopUpdateDownloadedToast(bridge, result.state);
}
if (!shouldToastDesktopUpdateActionResult(result)) return;
const actionError = getDesktopUpdateActionError(result);
if (!actionError) return;
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not download update",
description: actionError,
}),
);
})
.catch((error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not start update download",
description: error instanceof Error ? error.message : "An unexpected error occurred.",
}),
);
})
.finally(() => setIsActionPending(false));
return;
}
if (action === "install") {
let confirmed = false;
try {
confirmed = await ensureLocalApi().dialogs.confirm(
getDesktopUpdateInstallConfirmationMessage(state),
);
} catch (error) {
setIsActionPending(false);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not confirm update",
description: error instanceof Error ? error.message : "Update confirmation failed.",
}),
);
return;
}
if (!confirmed) {
setIsActionPending(false);
return;
}
void bridge
.installUpdate()
.then((result) => {
if (!shouldToastDesktopUpdateActionResult(result)) return;
const actionError = getDesktopUpdateActionError(result);
if (!actionError) return;
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not install update",
description: actionError,
}),
);
})
.catch((error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not install update",
description: error instanceof Error ? error.message : "An unexpected error occurred.",
}),
);
})
.finally(() => setIsActionPending(false));
return;
}
if (!prefersReducedMotion) {
setIsCheckAnimationLatched(true);
setCheckAnimationKey((key) => key + 1);
}
void bridge
.checkForUpdate()
.then((result) => {
if (result.checked) return;
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not check for updates",
description:
result.state.message ?? "Automatic updates are not available in this build.",
}),
);
})
.catch((error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not check for updates",
description: error instanceof Error ? error.message : "Update check failed.",
}),
);
})
.finally(() => setIsActionPending(false));
}, [action, isInteractionDisabled, prefersReducedMotion, state]);
const handleCheckAnimationIteration = useCallback(() => {
setIsCheckAnimationLatched(
shouldContinueDesktopUpdateCheckAnimation({
isChecking: state?.status === "checking",
prefersReducedMotion,
}),
);
}, [prefersReducedMotion, state?.status]);
return (
<SidebarMenuItem className="ml-auto shrink-0">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={tooltip}
aria-disabled={isInteractionDisabled || undefined}
className={cn(
"inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2",
isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer",
showUpdateIconState
? cn(
"bg-update-surface text-update-foreground",
!isInteractionDisabled && "hover:bg-update/12",
)
: cn(
"text-[var(--sidebar-icon-color)]",
!isInteractionDisabled &&
"hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
),
disabled && !showUpdateIconState && "opacity-60",
)}
onClick={handleAction}
>
<DesktopUpdateStatusIcon
key={showCheckIcon ? checkAnimationKey : iconStatus}
downloadPercent={state?.downloadPercent ?? null}
isCheckAnimating={showCheckIcon && !prefersReducedMotion}
onCheckAnimationIteration={handleCheckAnimationIteration}
status={iconStatus}
/>
</button>
}
/>
<TooltipPopup
align="center"
className={
showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0
? // pointer-events-auto overrides the positioner's pointer-events-none so the
// release notes stay open (and scrollable) when the cursor moves into them.
"pointer-events-auto max-w-none text-balance"
: undefined
}
side="top"
style={
showUpdateDetails
? {
background:
"color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",
borderColor: "var(--update-foreground)",
}
: undefined
}
variant={showUpdateDetails ? "glass" : "default"}
>
{showUpdateDetails && state ? (
<SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
) : (
tooltip
)}
</TooltipPopup>
</Tooltip>
</SidebarMenuItem>
);
}