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
2 changes: 2 additions & 0 deletions desktop-ui/src/components/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HttpsTrustNotice } from "./app/https-trust-notice";
import { useProjectCreateHotkey } from "./app/projects/use-project-create-hotkey";
import { useProjectSearchHotkey } from "./app/projects/use-project-search-hotkey";
import { TerminalPanel } from "./app/terminal/terminal-panel";
import { UpdateNotice } from "./app/update-notice";
import { DesktopAnalytics } from "./shared/desktop-analytics";
import { PageTransition } from "./shared/page-transition";
import { SharedNavigation } from "./shared/shared-navigation";
Expand Down Expand Up @@ -43,6 +44,7 @@ function AppLayoutContent() {
<DesktopAnalytics />
{!isOpen && !outputOpen ? <SharedNavigation /> : null}
<HttpsTrustNotice />
<UpdateNotice />
<main className="flex-1 overflow-hidden rounded-lg">
<PageTransition />
</main>
Expand Down
24 changes: 8 additions & 16 deletions desktop-ui/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
loadStoredOnboardingCompleted,
markStoredOnboardingCompleted,
} from "@/lib/util.onboarding-state";
import { AppUpdateProvider } from "@/providers/app-update-provider";

import { SpinnerIcon } from "./icons";
import { Onboarding } from "./onboarding";
Expand Down Expand Up @@ -83,20 +82,13 @@ export const AppShell = ({ children }: AppShellProps) => {
markStoredOnboardingCompleted();
};

return (
<AppUpdateProvider
desktopName={data.environment.desktopName}
enabled={data.daemon.caInstalled || onboardingCompleted}
>
{needsSetup ? (
<Onboarding
bootstrap={data}
onActivated={handleOnboardingActivated}
startAtActivation={onboardingCompleted}
/>
) : (
<>{children}</>
)}
</AppUpdateProvider>
return needsSetup ? (
<Onboarding
bootstrap={data}
onActivated={handleOnboardingActivated}
startAtActivation={onboardingCompleted}
/>
) : (
<>{children}</>
);
};
106 changes: 106 additions & 0 deletions desktop-ui/src/components/app/restart-to-update.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"use client";

import { useMutation } from "@tanstack/react-query";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { systemMutations } from "@/lib/queries";
import { restartCloses } from "@/lib/util.app-update";
import { useTerminal } from "@/providers/terminal-provider";

interface RestartToUpdateProps {
appName: string;
version: string;
size?: "xs" | "sm";
}

/**
* `Restart to update`: names the tabs a restart closes, then hands the install to Localify's
* installer and quits. The app never restarts by itself.
*/
export const RestartToUpdate = ({ appName, version, size = "sm" }: RestartToUpdateProps) => {
const { sessions } = useTerminal();
const restart = useMutation(systemMutations.restartToUpdate());
const [open, setOpen] = useState(false);
const closes = restartCloses(sessions);
const closesNothing = closes.services.length === 0 && closes.shells.length === 0;

return (
<>
<Button
size={size}
variant="primary"
onClick={() => {
restart.reset();
setOpen(true);
}}
>
Restart to update
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Restart to update?</DialogTitle>
<DialogDescription>
{appName} quits, installs {version}, then opens again.
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-3 text-sm">
{closesNothing ? (
<p className="text-muted-foreground">No service or shell tab is open.</p>
) : (
<>
<ClosedTabs label="Services that stop" names={closes.services} />
<ClosedTabs label="Shells that close" names={closes.shells} />
</>
)}
{restart.error ? (
<p className="text-xs leading-normal text-destructive">{restart.error.message}</p>
) : null}
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="outline" />} disabled={restart.isPending}>
Cancel
</DialogClose>
<Button
type="button"
variant="primary"
loading={restart.isPending}
onClick={() => restart.mutate()}
>
Restart to update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

const ClosedTabs = ({ label, names }: { label: string; names: string[] }) => {
if (names.length === 0) return null;

return (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<ul className="space-y-0.5">
{names.map((name, index) => (
// Two shells in one checkout share a name.
<li key={`${name}-${index}`} className="truncate text-foreground">
{name}
</li>
))}
</ul>
</div>
);
};
2 changes: 1 addition & 1 deletion desktop-ui/src/components/app/settings/settings-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const SettingsScreen = () => {
<div className="space-y-4">
<SettingsStatus />
<SettingsStartup />
<SettingsUpdates />
<SettingsTroubleshooting />
<SettingsLaunchers />
<SettingsAgents config={data.config} />
Expand All @@ -70,7 +71,6 @@ export const SettingsScreen = () => {
<SettingsTransfer />
<SettingsHotkeys />
<SettingsCli />
<SettingsUpdates />
<SettingsUninstall />
{import.meta.env.DEV ? <SettingsDeveloper /> : null}
</div>
Expand Down
146 changes: 69 additions & 77 deletions desktop-ui/src/components/app/settings/settings-update-card.tsx
Original file line number Diff line number Diff line change
@@ -1,95 +1,87 @@
"use client";

import { motion } from "motion/react";
import type { ReactNode } from "react";

import type { UpdateStatusDto } from "@/types/localify";

import { DownloadIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import type { AppUpdateStatus } from "@/providers/app-update-provider";
import type { AppUpdateInfoDto } from "@/types/localify";
import { Switch } from "@/components/ui/switch";
import { describeUpdate } from "@/lib/util.app-update";

import { SettingsCard } from "./settings-card";

interface SettingsUpdateCardProps {
status: AppUpdateStatus;
availableUpdate: AppUpdateInfoDto | null;
errorMessage: string | null;
onCheckForUpdates: () => void;
onInstallUpdate: () => void;
appName: string;
/** Null while it is read. */
status: UpdateStatusDto | null;
/** Unix seconds, for the last check's age. */
now: number;
error: string | null;
automatic: boolean;
automaticDisabled: boolean;
onAutomaticChange: (automatic: boolean) => void;
checking: boolean;
onCheck: () => void;
/** Shown in place of `Check now` when an update can be installed. */
restart: ReactNode;
}

export const SettingsUpdateCard = ({
appName,
status,
availableUpdate,
errorMessage,
onCheckForUpdates,
onInstallUpdate,
now,
error,
automatic,
automaticDisabled,
onAutomaticChange,
checking,
onCheck,
restart,
}: SettingsUpdateCardProps) => {
const isChecking = status === "checking";
const isInstalling = status === "installing";
const hasUpdate = Boolean(availableUpdate);
const title = hasUpdate
? `Update ready - v${availableUpdate?.version}`
: status === "upToDate"
? "You're up to date"
: status === "unconfigured"
? "Updates unavailable"
: "Desktop updates";
const description = hasUpdate
? "A new Localify build is ready to download and install."
: status === "unconfigured"
? "Auto-updates are not configured for this build."
: "Checks for new Localify builds in the background and on demand.";

const action = hasUpdate ? (
<Button
size="lg"
variant="primary"
loading={isInstalling}
disabled={isChecking}
onClick={onInstallUpdate}
>
Install
</Button>
) : (
<Button
size="lg"
variant="outline"
loading={isChecking}
disabled={isInstalling || status === "unconfigured"}
onClick={onCheckForUpdates}
>
Check now
</Button>
);
const summary = status ? describeUpdate(status, appName, now) : null;
const busy = status?.state === "checking" || status?.state === "installing";

return (
<section className="space-y-2">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", duration: 0.5, bounce: 0, delay: 0.1 }}
className="relative isolate overflow-hidden rounded-2xl border border-border/40 bg-neutral-50/80 px-6 py-5 shadow-[0_1px_0_0_rgba(255,255,255,0.6)_inset] transition-colors duration-200 hover:border-border dark:bg-neutral-900/50 dark:shadow-[0_1px_0_0_rgba(255,255,255,0.04)_inset]"
>
<div className="flex items-center gap-5">
<span
aria-hidden
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background text-foreground/80 ring-1 ring-border/60 [&>svg]:size-4"
>
<DownloadIcon />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-[17px] font-semibold tracking-tight text-foreground">
{title}
</h3>
<p className="max-w-md text-[13.5px] leading-normal text-pretty text-muted-foreground">
{description}
<SettingsCard
icon={<DownloadIcon />}
title="Install updates automatically"
description={`New versions install while ${appName} is closed. While it's open, they wait for you to restart.`}
trailing={
<Switch
aria-label="Install updates automatically"
checked={automatic}
disabled={automaticDisabled}
onCheckedChange={onAutomaticChange}
/>
}
>
<div className="mt-4 flex items-center gap-4 border-t border-border/40 pt-4">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-foreground">
{summary?.title ?? "Reading the update status…"}
</p>
{summary?.detail ? (
<p className="text-xs leading-normal text-pretty text-muted-foreground">
{summary.detail}
</p>
</div>
{action}
) : null}
</div>
</motion.div>

{errorMessage ? (
<p className="px-1 text-xs text-[--color-danger]">{errorMessage}</p>
) : null}
</section>
{summary?.canRestart ? (
restart
) : (
<Button
size="sm"
variant="outline"
loading={checking}
disabled={!status || busy}
onClick={onCheck}
>
Check now
</Button>
)}
</div>
{error ? <p className="pt-3 text-xs leading-normal text-destructive">{error}</p> : null}
</SettingsCard>
);
};
Loading
Loading