| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | 1 | import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; |
| 2 | import { useCallback, useState } from "react"; | 2 | import { useCallback, useState } from "react"; |
| 3 | import { isElectron } from "../../env"; | 3 | import { isElectron } from "../../env"; |
| | 4 | import { cn } from "../../lib/utils"; |
| 4 | import { ensureLocalApi } from "../../localApi"; | 5 | import { ensureLocalApi } from "../../localApi"; |
| 5 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; | 6 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 6 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 7 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 7 | import { | 8 | import { |
| | 9 | canCheckForUpdate, |
| 8 | getArm64IntelBuildWarningDescription, | 10 | getArm64IntelBuildWarningDescription, |
| 9 | getDesktopUpdateActionError, | 11 | getDesktopUpdateActionError, |
| 10 | getDesktopUpdateButtonTooltip, | 12 | getDesktopUpdateButtonTooltip, |
| ... | @@ -12,12 +14,12 @@ import { | ... | @@ -12,12 +14,12 @@ import { |
| 12 | isDesktopUpdateButtonDisabled, | 14 | isDesktopUpdateButtonDisabled, |
| 13 | resolveDesktopUpdateButtonAction, | 15 | resolveDesktopUpdateButtonAction, |
| 14 | shouldShowArm64IntelBuildWarning, | 16 | shouldShowArm64IntelBuildWarning, |
| 15 | shouldShowDesktopUpdateButton, | | |
| 16 | shouldToastDesktopUpdateActionResult, | 17 | shouldToastDesktopUpdateActionResult, |
| 17 | } from "../desktopUpdate.logic"; | 18 | } from "../desktopUpdate.logic"; |
| 18 | import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; | 19 | import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; |
| 19 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | 20 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 20 | import { Separator } from "../ui/separator"; | 21 | import { Separator } from "../ui/separator"; |
| | 22 | import { SidebarMenuItem } from "../ui/sidebar"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 23 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 22 | | 24 | |
| 23 | function keyReleaseNoteItems(items: ReadonlyArray<string>) { | 25 | function keyReleaseNoteItems(items: ReadonlyArray<string>) { |
| ... | @@ -41,16 +43,29 @@ function SidebarUpdateReleaseNotesTooltip({ | ... | @@ -41,16 +43,29 @@ function SidebarUpdateReleaseNotesTooltip({ |
| 41 | } | 43 | } |
| 42 | | 44 | |
| 43 | return ( | 45 | return ( |
| 44 | <div className="w-120 max-w-[calc(100vw-2rem)] text-left"> | 46 | <div className="w-fit max-w-[min(24rem,calc(100vw-2rem))] text-left"> |
| 45 | <div className="px-1"> | 47 | <div className="px-1"> |
| 46 | <div className="text-sm leading-5 font-medium">{tooltip}</div> | 48 | {state.status === "available" ? ( |
| | 49 | <div> |
| | 50 | <div className="whitespace-nowrap text-sm leading-5 font-medium"> |
| | 51 | Update ready to download |
| | 52 | </div> |
| | 53 | {state.availableVersion ? ( |
| | 54 | <div className="mt-0.5 text-xs leading-4 text-update-foreground"> |
| | 55 | {state.availableVersion} |
| | 56 | </div> |
| | 57 | ) : null} |
| | 58 | </div> |
| | 59 | ) : ( |
| | 60 | <div className="text-sm leading-5 font-medium">{tooltip}</div> |
| | 61 | )} |
| 47 | </div> | 62 | </div> |
| 48 | <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1"> | 63 | <div className="max-h-[min(28rem,calc(100vh-6rem))] overflow-y-auto px-1 pt-4 pb-1"> |
| 49 | {state.releaseNotes.map((releaseNote, index) => ( | 64 | {state.releaseNotes.map((releaseNote, index) => ( |
| 50 | <div key={releaseNote.version}> | 65 | <div key={releaseNote.version}> |
| 51 | {index > 0 && <Separator className="my-3 bg-border/60" />} | 66 | {index > 0 && <Separator className="my-3 bg-border/60" />} |
| 52 | <section> | 67 | <section> |
| 53 | <h3 className="text-muted-foreground text-xs leading-4 font-semibold"> | 68 | <h3 className="text-foreground text-xs leading-4 font-semibold"> |
| 54 | {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} | 69 | {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} |
| 55 | </h3> | 70 | </h3> |
| 56 | <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90"> | 71 | <ul className="mt-2 space-y-1.5 pl-4 text-xs leading-5 text-popover-foreground/90"> |
| ... | @@ -68,24 +83,50 @@ function SidebarUpdateReleaseNotesTooltip({ | ... | @@ -68,24 +83,50 @@ function SidebarUpdateReleaseNotesTooltip({ |
| 68 | ); | 83 | ); |
| 69 | } | 84 | } |
| 70 | | 85 | |
| | 86 | export function SidebarUpdateArchitectureWarning() { |
| | 87 | return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null; |
| | 88 | } |
| | 89 | |
| | 90 | function SidebarUpdateArchitectureWarningContent() { |
| | 91 | const state = useDesktopUpdateState(); |
| | 92 | const visible = shouldShowArm64IntelBuildWarning(state); |
| | 93 | const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null; |
| | 94 | |
| | 95 | if (!visible || !description) return null; |
| | 96 | |
| | 97 | return ( |
| | 98 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs"> |
| | 99 | <TriangleAlertIcon /> |
| | 100 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> |
| | 101 | <AlertDescription>{description}</AlertDescription> |
| | 102 | </Alert> |
| | 103 | ); |
| | 104 | } |
| | 105 | |
| 71 | export function SidebarUpdatePill() { | 106 | export function SidebarUpdatePill() { |
| | 107 | return isElectron ? <SidebarUpdateControl /> : null; |
| | 108 | } |
| | 109 | |
| | 110 | function SidebarUpdateControl() { |
| 72 | const state = useDesktopUpdateState(); | 111 | const state = useDesktopUpdateState(); |
| 73 | const [dismissed, setDismissed] = useState(false); | | |
| 74 | const [isActionPending, setIsActionPending] = useState(false); | 112 | const [isActionPending, setIsActionPending] = useState(false); |
| 75 | | 113 | |
| 76 | const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; | | |
| 77 | const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; | | |
| 78 | const disabled = isDesktopUpdateButtonDisabled(state); | | |
| 79 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; | 114 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; |
| 80 | | 115 | const isDownloading = state?.status === "downloading"; |
| 81 | const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); | 116 | const isUpdateState = action !== "none" || isDownloading; |
| 82 | const arm64Description = | 117 | const tooltip = isUpdateState |
| 83 | state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; | 118 | ? state |
| | 119 | ? getDesktopUpdateButtonTooltip(state) |
| | 120 | : "Update available" |
| | 121 | : state?.status === "checking" |
| | 122 | ? "Checking for updates…" |
| | 123 | : "Check for updates"; |
| | 124 | const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state); |
| 84 | | 125 | |
| 85 | const handleAction = useCallback(async () => { | 126 | const handleAction = useCallback(async () => { |
| 86 | const bridge = window.desktopBridge; | 127 | const bridge = window.desktopBridge; |
| 87 | if (!bridge || !state) return; | 128 | if (!bridge || !state) return; |
| 88 | if (disabled || action === "none" || isActionPending) return; | 129 | if (disabled || isActionPending) return; |
| 89 | | 130 | |
| 90 | setIsActionPending(true); | 131 | setIsActionPending(true); |
| 91 | | 132 | |
| ... | @@ -165,99 +206,92 @@ export function SidebarUpdatePill() { | ... | @@ -165,99 +206,92 @@ export function SidebarUpdatePill() { |
| 165 | ); | 206 | ); |
| 166 | }) | 207 | }) |
| 167 | .finally(() => setIsActionPending(false)); | 208 | .finally(() => setIsActionPending(false)); |
| | 209 | return; |
| 168 | } | 210 | } |
| 169 | }, [action, disabled, isActionPending, state]); | | |
| 170 | | 211 | |
| 171 | if (!visible && !showArm64Warning) return null; | 212 | void bridge |
| | 213 | .checkForUpdate() |
| | 214 | .then((result) => { |
| | 215 | if (result.checked) return; |
| | 216 | toastManager.add( |
| | 217 | stackedThreadToast({ |
| | 218 | type: "error", |
| | 219 | title: "Could not check for updates", |
| | 220 | description: |
| | 221 | result.state.message ?? "Automatic updates are not available in this build.", |
| | 222 | }), |
| | 223 | ); |
| | 224 | }) |
| | 225 | .catch((error) => { |
| | 226 | toastManager.add( |
| | 227 | stackedThreadToast({ |
| | 228 | type: "error", |
| | 229 | title: "Could not check for updates", |
| | 230 | description: error instanceof Error ? error.message : "Update check failed.", |
| | 231 | }), |
| | 232 | ); |
| | 233 | }) |
| | 234 | .finally(() => setIsActionPending(false)); |
| | 235 | }, [action, disabled, isActionPending, state]); |
| 172 | | 236 | |
| 173 | return ( | 237 | return ( |
| 174 | <div className="flex flex-col gap-1"> | 238 | <SidebarMenuItem className="ml-auto shrink-0"> |
| 175 | {showArm64Warning && arm64Description && ( | 239 | <Tooltip> |
| 176 | <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs"> | 240 | <TooltipTrigger |
| 177 | <TriangleAlertIcon /> | 241 | render={ |
| 178 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> | 242 | <button |
| 179 | <AlertDescription>{arm64Description}</AlertDescription> | 243 | type="button" |
| 180 | </Alert> | 244 | aria-label={tooltip} |
| 181 | )} | 245 | aria-disabled={disabled || isActionPending || undefined} |
| 182 | {visible && ( | 246 | disabled={disabled || isActionPending} |
| 183 | <div | 247 | className={cn( |
| 184 | className={`group/update relative flex h-7 w-full items-center rounded-lg bg-update-surface text-xs font-medium text-update-foreground ${ | 248 | "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60", |
| 185 | disabled ? " cursor-not-allowed opacity-60" : "" | 249 | isUpdateState |
| 186 | }`} | 250 | ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12" |
| 187 | > | 251 | : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", |
| 188 | <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors group-has-[button.update-main:hover]/update:bg-update/12" /> | 252 | )} |
| 189 | <Tooltip> | 253 | onClick={handleAction} |
| 190 | <TooltipTrigger | | |
| 191 | render={ | | |
| 192 | <button | | |
| 193 | type="button" | | |
| 194 | aria-label={tooltip} | | |
| 195 | aria-disabled={disabled || isActionPending || undefined} | | |
| 196 | disabled={disabled || isActionPending} | | |
| 197 | className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer" | | |
| 198 | onClick={handleAction} | | |
| 199 | > | | |
| 200 | {action === "install" ? ( | | |
| 201 | <> | | |
| 202 | <RotateCwIcon className="size-3.5" /> | | |
| 203 | <span>Restart to update</span> | | |
| 204 | </> | | |
| 205 | ) : state?.status === "downloading" ? ( | | |
| 206 | <> | | |
| 207 | <DownloadIcon className="size-3.5" /> | | |
| 208 | <span> | | |
| 209 | Downloading | | |
| 210 | {typeof state.downloadPercent === "number" | | |
| 211 | ? ` (${Math.floor(state.downloadPercent)}%)` | | |
| 212 | : "…"} | | |
| 213 | </span> | | |
| 214 | </> | | |
| 215 | ) : ( | | |
| 216 | <> | | |
| 217 | <DownloadIcon className="size-3.5" /> | | |
| 218 | <span>Update available</span> | | |
| 219 | </> | | |
| 220 | )} | | |
| 221 | </button> | | |
| 222 | } | | |
| 223 | /> | | |
| 224 | <TooltipPopup | | |
| 225 | align="start" | | |
| 226 | className={ | | |
| 227 | state?.channel === "nightly" && state.releaseNotes.length > 0 | | |
| 228 | ? // pointer-events-auto overrides the positioner's pointer-events-none so the | | |
| 229 | // release notes stay open (and scrollable) when the cursor moves into them. | | |
| 230 | "pointer-events-auto max-w-none text-balance" | | |
| 231 | : undefined | | |
| 232 | } | | |
| 233 | side="top" | | |
| 234 | > | 254 | > |
| 235 | {state ? ( | 255 | {action === "install" ? ( |
| 236 | <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} /> | 256 | <RotateCwIcon className="size-4" /> |
| | 257 | ) : isUpdateState ? ( |
| | 258 | <DownloadIcon className="size-4" /> |
| 237 | ) : ( | 259 | ) : ( |
| 238 | tooltip | 260 | <RefreshCwIcon |
| | 261 | className={cn("size-4", state?.status === "checking" && "animate-spin")} |
| | 262 | /> |
| 239 | )} | 263 | )} |
| 240 | </TooltipPopup> | 264 | </button> |
| 241 | </Tooltip> | 265 | } |
| 242 | {action === "download" && ( | 266 | /> |
| 243 | <Tooltip> | 267 | <TooltipPopup |
| 244 | <TooltipTrigger | 268 | align="center" |
| 245 | render={ | 269 | className={ |
| 246 | <button | 270 | isUpdateState && state?.channel === "nightly" && state.releaseNotes.length > 0 |
| 247 | type="button" | 271 | ? // pointer-events-auto overrides the positioner's pointer-events-none so the |
| 248 | aria-label="Dismiss update" | 272 | // release notes stay open (and scrollable) when the cursor moves into them. |
| 249 | className="mr-1 inline-flex size-5 items-center justify-center rounded-md text-update-foreground transition-colors" | 273 | "pointer-events-auto max-w-none text-balance" |
| 250 | onClick={() => setDismissed(true)} | 274 | : undefined |
| 251 | > | 275 | } |
| 252 | <XIcon className="size-3.5" /> | 276 | side="top" |
| 253 | </button> | 277 | style={ |
| | 278 | isUpdateState |
| | 279 | ? { |
| | 280 | background: |
| | 281 | "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", |
| | 282 | borderColor: "var(--update-foreground)", |
| 254 | } | 283 | } |
| 255 | /> | 284 | : undefined |
| 256 | <TooltipPopup side="top">Dismiss until next launch</TooltipPopup> | 285 | } |
| 257 | </Tooltip> | 286 | variant={isUpdateState ? "glass" : "default"} |
| | 287 | > |
| | 288 | {isUpdateState && state ? ( |
| | 289 | <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} /> |
| | 290 | ) : ( |
| | 291 | tooltip |
| 258 | )} | 292 | )} |
| 259 | </div> | 293 | </TooltipPopup> |
| 260 | )} | 294 | </Tooltip> |
| 261 | </div> | 295 | </SidebarMenuItem> |
| 262 | ); | 296 | ); |
| 263 | } | 297 | } |