Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
shouldShowArm64IntelBuildWarning,
shouldToastDesktopUpdateActionResult,
} from "./desktopUpdate.logic";
import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import {
Expand Down Expand Up @@ -3509,11 +3510,7 @@ export default function Sidebar() {
.downloadUpdate()
.then((result) => {
if (result.completed) {
toastManager.add({
type: "success",
title: "Update downloaded",
description: "Restart the app from the update button to install it.",
});
showDesktopUpdateDownloadedToast(bridge, result.state);
}
if (!shouldToastDesktopUpdateActionResult(result)) return;
const actionError = getDesktopUpdateActionError(result);
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
getDesktopUpdateInstallConfirmationMessage,
getDesktopUpdateReleaseUrl,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldShowArm64IntelBuildWarning,
Expand Down Expand Up @@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => {
});

describe("desktop update UI helpers", () => {
it("builds the stable release URL for a downloaded version", () => {
expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe(
"https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
);
});

it("builds the nightly release URL without dropping its version suffix", () => {
expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe(
"https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931",
);
});

it("omits the release URL when the updater does not report a version", () => {
expect(getDesktopUpdateReleaseUrl(null)).toBeNull();
expect(getDesktopUpdateReleaseUrl(" ")).toBeNull();
});

it("toasts only for actionable updater errors", () => {
expect(
shouldToastDesktopUpdateActionResult({
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils";

export type DesktopUpdateButtonAction = "download" | "install" | "none";

const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag";

/**
* The main process fills `downloadedVersion` from the updater's `update-downloaded`
* event, which is dispatched on its own fiber. A download RPC can therefore resolve
* before that write lands, so fall back to the version the download was started for.
*/
export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null {
return state.downloadedVersion ?? state.availableVersion;
}

/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */
export function getDesktopUpdateReleaseUrl(version: string | null): string | null {
const normalizedVersion = version?.trim();
if (!normalizedVersion) return null;
return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`;
}

export function resolveDesktopUpdateButtonAction(
state: DesktopUpdateState,
): DesktopUpdateButtonAction {
Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/components/desktopUpdate.toast.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import type { DesktopUpdateState } from "@t3tools/contracts";

const testState = vi.hoisted(() => ({
addToast: vi.fn(),
}));

vi.mock("./ui/toast", () => ({
toastManager: { add: testState.addToast },
}));

import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";

type ClickableElement = ReactElement<{ readonly onClick?: () => void }>;

/** Walks the rendered description, invoking function components, to find the link button. */
function findReleaseNotesLink(node: ReactNode): ClickableElement | null {
if (Array.isArray(node)) {
for (const child of node) {
const found = findReleaseNotesLink(child);
if (found) return found;
}
return null;
}
if (!isValidElement(node)) return null;
const element = node as ReactElement<{ readonly children?: ReactNode }>;
if (element.type === "button") return element as ClickableElement;
if (typeof element.type === "function") {
const render = element.type as (props: unknown) => ReactNode;
return findReleaseNotesLink(render(element.props));
}
return findReleaseNotesLink(element.props.children);
}

function getDescription(): ReactNode {
const toast = testState.addToast.mock.calls[0]?.[0] as { description?: ReactNode } | undefined;
return toast?.description ?? null;
}

function downloadedState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
return {
enabled: true,
status: "downloaded",
channel: "latest",
currentVersion: "0.0.29",
hostArch: "arm64",
appArch: "arm64",
runningUnderArm64Translation: false,
availableVersion: "0.0.30",
downloadedVersion: "0.0.30",
releaseNotes: [],
downloadPercent: 100,
checkedAt: null,
message: null,
errorContext: null,
canRetry: true,
...overrides,
};
}

describe("showDesktopUpdateDownloadedToast", () => {
beforeEach(() => {
testState.addToast.mockReset();
});

it("opens the downloaded version's release notes", async () => {
const openExternal = vi.fn().mockResolvedValue(true);

showDesktopUpdateDownloadedToast({ openExternal }, downloadedState());
const link = findReleaseNotesLink(getDescription());
link?.props.onClick?.();
await vi.waitFor(() => {
expect(openExternal).toHaveBeenCalledWith(
"https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
);
});
expect(testState.addToast).toHaveBeenCalledTimes(1);
});

it("falls back to the version the download was started for", async () => {
const openExternal = vi.fn().mockResolvedValue(true);

// The `update-downloaded` event can land after the download RPC resolves.
showDesktopUpdateDownloadedToast(
{ openExternal },
downloadedState({ downloadedVersion: null }),
);
findReleaseNotesLink(getDescription())?.props.onClick?.();

await vi.waitFor(() => {
expect(openExternal).toHaveBeenCalledWith(
"https://github.com/pingdotgg/t3code/releases/tag/v0.0.30",
);
});
});

it("omits the link when the updater reports no version at all", () => {
showDesktopUpdateDownloadedToast(
{ openExternal: vi.fn() },
downloadedState({ availableVersion: null, downloadedVersion: null }),
);

expect(findReleaseNotesLink(getDescription())).toBeNull();
});

it.each([
["returns false", vi.fn().mockResolvedValue(false)],
["rejects", vi.fn().mockRejectedValue(new Error("open failed"))],
])("shows an error when opening release notes %s", async (_description, openExternal) => {
showDesktopUpdateDownloadedToast({ openExternal }, downloadedState());
findReleaseNotesLink(getDescription())?.props.onClick?.();

await vi.waitFor(() => {
expect(testState.addToast).toHaveBeenLastCalledWith({
type: "error",
title: "Unable to open release notes",
});
});
});
});
55 changes: 55 additions & 0 deletions apps/web/src/components/desktopUpdate.toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
import { ArrowRightIcon } from "lucide-react";

import {
getDesktopUpdateDownloadedVersion,
getDesktopUpdateReleaseUrl,
} from "./desktopUpdate.logic";
import { toastManager } from "./ui/toast";

type DesktopUpdateShell = Pick<DesktopBridge, "openExternal">;

function ReleaseNotesLink({
shell,
releaseUrl,
}: {
shell: DesktopUpdateShell;
releaseUrl: string;
}) {
return (
<button
className="ml-2 inline-flex cursor-pointer items-center gap-1 align-baseline text-muted-foreground underline decoration-dotted underline-offset-4 transition-colors hover:text-foreground"
onClick={() => {
void (async () => {
try {
if (await shell.openExternal(releaseUrl)) return;
} catch {
// Surface rejected IPC calls through the same user-visible fallback.
}
toastManager.add({ type: "error", title: "Unable to open release notes" });
})();
}}
type="button"
>
Read more
<ArrowRightIcon aria-hidden className="size-3 -rotate-45" strokeWidth={2.25} />
</button>
);
}

export function showDesktopUpdateDownloadedToast(
shell: DesktopUpdateShell,
state: DesktopUpdateState,
): void {
const releaseUrl = getDesktopUpdateReleaseUrl(getDesktopUpdateDownloadedVersion(state));
toastManager.add({
type: "success",
title: "Update downloaded",
description: (
<>
Restart the app from the update button to install it.
{releaseUrl ? <ReleaseNotesLink releaseUrl={releaseUrl} shell={shell} /> : null}
</>
),
});
}
7 changes: 2 additions & 5 deletions apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
shouldShowDesktopUpdateButton,
shouldToastDesktopUpdateActionResult,
} from "../desktopUpdate.logic";
import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Separator } from "../ui/separator";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
Expand Down Expand Up @@ -80,11 +81,7 @@ export function SidebarUpdatePill() {
.downloadUpdate()
.then((result) => {
if (result.completed) {
toastManager.add({
type: "success",
title: "Update downloaded",
description: "Restart the app from the update button to install it.",
});
showDesktopUpdateDownloadedToast(bridge, result.state);
}
if (!shouldToastDesktopUpdateActionResult(result)) return;
const actionError = getDesktopUpdateActionError(result);
Expand Down
Loading