Skip to content
Open
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
4 changes: 3 additions & 1 deletion apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ function SwitchPlan({
// After a partial switch this app may already be on the track; then only remotes move.
const switchesLocal = crossesLocal && !updatesOff;
const [checking, setChecking] = useState(false);
const everything = useUpdateEverything(switchesLocal ? release.version : null, release);
const everything = useUpdateEverything(switchesLocal ? release.version : null, {
channelSwitch: release,
});
const label = channelSwitchLabel(release.channel);
// This app's reinstall restarts every server it hosts, so each of their databases must allow it.
const localBlocked = localSwitchBlockReason(
Expand Down
102 changes: 91 additions & 11 deletions apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => {
return {
update: null as DesktopUpdateState | null,
hasUpdates: false,
hasProviderUpdates: false,
hasAppUpdates: false,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
Expand All @@ -33,16 +35,28 @@ vi.mock("../../env", async (importOriginal) => ({
...(await importOriginal<typeof import("../../env")>()),
isElectron: true,
}));
vi.mock("./useUpdateEverything", () => ({
useUpdateEverything: () => ({
hasUpdates: mocks.hasUpdates,
summary: "Update everything",
version: null,
providerCount: 0,
rows: [],
restarts: [],
}),
}));
vi.mock("./useUpdateEverything", async () => {
const { useSyncExternalStore } = await import("react");
return {
useUpdateEverything: (
_version: string | null,
options?: { includeProviderUpdates: boolean },
) => {
const hasAppUpdates = useSyncExternalStore(mocks.subscribe, () => mocks.hasAppUpdates);
return {
hasUpdates: mocks.hasUpdates,
hasAppUpdates: _version !== null || hasAppUpdates,
canChooseProviderUpdates: mocks.hasProviderUpdates && (_version !== null || hasAppUpdates),
summary: "Update everything",
version: null,
providerCount:
mocks.hasProviderUpdates && options?.includeProviderUpdates !== false ? 1 : 0,
rows: [],
restarts: [],
};
},
};
});

function nightlyUpdate(releaseNotes: DesktopUpdateState["releaseNotes"]): DesktopUpdateState {
return {
Expand All @@ -69,9 +83,14 @@ const RELEASE_NOTES: DesktopUpdateState["releaseNotes"] = [
{ version: "0.0.50-nightly.2", items: ["Faster sidebar."], totalItems: 1 },
];

function setup(update: DesktopUpdateState | null, { hasUpdates = false } = {}) {
function setup(
update: DesktopUpdateState | null,
{ hasUpdates = false, hasProviderUpdates = false, hasAppUpdates = false } = {},
) {
mocks.update = update;
mocks.hasUpdates = hasUpdates;
mocks.hasProviderUpdates = hasProviderUpdates;
mocks.hasAppUpdates = hasAppUpdates;
}

function publish(update: DesktopUpdateState | null) {
Expand Down Expand Up @@ -106,6 +125,67 @@ describe("SidebarUpdatePill release notes", () => {
});

describe("SidebarUpdatePill update everything", () => {
it("requires a new confirmation if the last app-update target disappears", async () => {
setup(null, { hasUpdates: true, hasProviderUpdates: true, hasAppUpdates: true });
await using screen = await render(<SidebarUpdatePill />);
const button = screen.getByRole("button", { name: "Update everything" });
await userEvent.click(button);
const checkbox = page.getByRole("checkbox", { name: "Include provider updates" });
await expect.element(checkbox).toBeVisible();

await act(() => {
mocks.hasAppUpdates = false;
mocks.notify();
});
await expect.element(page.getByRole("alertdialog")).not.toBeInTheDocument();
await userEvent.click(button);
await expect.element(page.getByRole("alertdialog")).toBeVisible();
await expect.element(checkbox).not.toBeInTheDocument();
});

it.each([false, true])(
"remembers the provider choice in another client (already mounted: %s)",
async (alreadyMounted) => {
setup(nightlyUpdate([]), { hasUpdates: true, hasProviderUpdates: true });
const key = "t3code:include-providers-in-app-updates";
const previous = localStorage.getItem(key);
using _storage = {
[Symbol.dispose]() {
if (previous === null) localStorage.removeItem(key);
else localStorage.setItem(key, previous);
},
};
localStorage.removeItem(key);
const renderClient = (id: string) => {
const container = document.createElement("div");
container.id = id;
return render(<SidebarUpdatePill />, { container });
};
await using clients = new AsyncDisposableStack();
const screen = clients.use(await renderClient("update-client-first"));
const other = alreadyMounted ? clients.use(await renderClient("update-client-second")) : null;
await userEvent.click(screen.getByRole("button", { name: "Update everything" }));
const checkbox = page.getByRole("checkbox", { name: "Include provider updates" });
await expect.element(checkbox).toBeChecked();
await userEvent.click(checkbox);
await expect.element(checkbox).not.toBeChecked();
expect(localStorage.getItem(key)).toBe("false");
await screen.unmount();

const restored = other ?? clients.use(await renderClient("update-client-second"));
await userEvent.click(restored.getByRole("button", { name: "Update everything" }));
await expect.element(checkbox).not.toBeChecked();
await act(() => {
localStorage.setItem(key, "true");
window.dispatchEvent(new StorageEvent("storage", { key, newValue: "true" }));
});
await expect.element(checkbox).not.toBeChecked();
await userEvent.click(checkbox);
await expect.element(checkbox).toBeChecked();
expect(localStorage.getItem(key)).toBe("true");
},
);

it("does not refocus the update button after a pointer cancel", async () => {
setup(null, { hasUpdates: true });
await using screen = await render(<SidebarUpdatePill />);
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
import { TriangleAlertIcon } from "lucide-react";
import * as Schema from "effect/Schema";
import { type ComponentProps, useId, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { isElectron } from "../../env";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { useLocalStorage } from "../../hooks/useLocalStorage";
import { cn } from "../../lib/utils";
import { ensureLocalApi } from "../../localApi";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
Expand Down Expand Up @@ -44,6 +46,8 @@ import {
} from "./updateEverythingRun";
import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything";

const INCLUDE_PROVIDER_UPDATES_KEY = "t3code:include-providers-in-app-updates";

type SidebarUpdatePopoverChangeDetails = Parameters<
NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>
>[1];
Expand Down Expand Up @@ -194,6 +198,7 @@ function SidebarUpdateControl() {
const state = useDesktopUpdateState();
const [isActionPending, setIsActionPending] = useState(false);
const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
const [isConfirmingAppUpdates, setIsConfirmingAppUpdates] = useState(false);
const [checkAnimationKey, setCheckAnimationKey] = useState(0);
const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
const suppressReleaseNotesFocusOpen = useRef(false);
Expand Down Expand Up @@ -222,7 +227,21 @@ function SidebarUpdateControl() {
const isDownloading = state?.status === "downloading";
const localUpdateVersion =
state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null;
const everything = useUpdateEverything(localUpdateVersion);
const [savedIncludeProviderUpdates, saveIncludeProviderUpdates] = useLocalStorage(
INCLUDE_PROVIDER_UPDATES_KEY,
true,
Schema.Boolean,
);
const [includeProviderUpdates, setIncludeProviderUpdates] = useState(savedIncludeProviderUpdates);
const everything = useUpdateEverything(localUpdateVersion, {
includeProviderUpdates: isConfirmingEverything
? includeProviderUpdates
: savedIncludeProviderUpdates,
});
// A provider-only plan needs a new confirmation after app targets disappear.
if (isConfirmingEverything && isConfirmingAppUpdates && !everything.hasAppUpdates) {
setIsConfirmingEverything(false);
}
const runsEverything = everything.hasUpdates && !isDownloading;
const run = useUpdateEverythingRun();
const isRunningEverything = run !== null;
Expand Down Expand Up @@ -275,6 +294,8 @@ function SidebarUpdateControl() {
if (isInteractionDisabled) return;

if (runsEverything) {
setIncludeProviderUpdates(savedIncludeProviderUpdates);
setIsConfirmingAppUpdates(everything.hasAppUpdates);
setIsConfirmingEverything(true);
return;
}
Expand Down Expand Up @@ -401,6 +422,17 @@ function SidebarUpdateControl() {
providerCount={everything.providerCount}
rows={everything.rows}
restarts={everything.restarts}
{...(everything.canChooseProviderUpdates
? {
providerUpdates: {
included: includeProviderUpdates,
onIncludedChange: (included: boolean) => {
setIncludeProviderUpdates(included);
saveIncludeProviderUpdates(included);
},
},
}
: {})}
onConfirm={() => void runEverything()}
/>
);
Expand Down
40 changes: 29 additions & 11 deletions apps/web/src/components/sidebar/UpdateEverythingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
import { MiddleTruncate } from "../ui/middle-truncate";
import { ScrollArea } from "../ui/scroll-area";
Expand Down Expand Up @@ -47,6 +48,10 @@ interface UpdateEverythingDialogProps {
readonly rows: ReadonlyArray<UpdateEverythingRow>;
readonly restarts: ReadonlyArray<RestartingMachine>;
readonly onConfirm: () => void;
readonly providerUpdates?: {
readonly included: boolean;
readonly onIncludedChange: (included: boolean) => void;
};
/** Overrides the "Update N machines?" title, for an update-track switch. */
readonly title?: string;
readonly confirmLabel?: string;
Expand Down Expand Up @@ -96,6 +101,7 @@ function UpdateEverythingDialogContent({
rows,
restarts,
onConfirm,
providerUpdates,
cancelRef,
title,
confirmLabel = "Update",
Expand Down Expand Up @@ -213,26 +219,38 @@ function UpdateEverythingDialogContent({
) : null}
</div>
</ScrollArea>
<AlertDialogFooter>
<AlertDialogFooter className="flex-col sm:flex-col">
{disabledReason !== null ? (
<p className="self-center text-sm max-sm:order-1 max-sm:text-center sm:mr-auto">
{disabledReason}
</p>
<p className="text-sm">{disabledReason}</p>
) : running > 0 ? (
<p className="self-center text-muted-foreground text-sm max-sm:order-1 max-sm:text-center sm:mr-auto">
<p className="text-muted-foreground text-sm">
{stopped === 0
? `${threads} will be interrupted and can resume after the restart.`
: continuing === 0
? `${threads} will be interrupted.`
: `${threads} will be interrupted. ${continuing} can resume after the restart.`}
</p>
) : null}
<AlertDialogClose ref={cancelRef} render={<Button variant="outline" disabled={busy} />}>
Cancel
</AlertDialogClose>
<Button disabled={disabledReason !== null || busy} onClick={onConfirm}>
{confirmLabel}
</Button>
<div className="flex flex-wrap items-center justify-end gap-3">
{providerUpdates ? (
<label className="mr-auto flex min-w-0 items-center gap-2 text-sm">
<Checkbox
checked={providerUpdates.included}
onCheckedChange={providerUpdates.onIncludedChange}
disabled={busy}
/>
Include provider updates
</label>
) : null}
<div className="flex gap-2">
<AlertDialogClose ref={cancelRef} render={<Button variant="outline" disabled={busy} />}>
Cancel
</AlertDialogClose>
<Button disabled={disabledReason !== null || busy} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</AlertDialogFooter>
</>
);
Expand Down
Loading
Loading