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
6 changes: 3 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { ExplorerSidebar } from "@/components/explorer/ExplorerSidebar";
import { ConvertWorkspacePrompt } from "@/components/explorer/ConvertWorkspacePrompt";
import { PathBreadcrumb } from "@/components/document/PathBreadcrumb";
import { PaneView } from "@/components/document/PaneView";
import { UpdateBanner } from "@/components/document/UpdateBanner";
import { UpdateToast } from "@/components/document/UpdateToast";

const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog"));
import { useLibrary } from "@/hooks/useLibrary";
Expand Down Expand Up @@ -536,6 +536,7 @@ function App() {
onChange={viewSettings.update}
initialSection={settingsSection}
onOpenWelcome={() => void handleOpenWelcome()}
updater={updater}
/>
</Suspense>
)}
Expand Down Expand Up @@ -588,13 +589,12 @@ function App() {

<SidebarInset className="flex h-svh flex-col pt-9">

<UpdateBanner
<UpdateToast
phase={updater.phase}
pendingVersion={updater.pendingVersion}
currentVersion={updater.currentVersion}
progressBytes={updater.progressBytes}
totalBytes={updater.totalBytes}
error={updater.error}
onInstall={updater.install}
onDismiss={updater.dismiss}
/>
Expand Down
93 changes: 0 additions & 93 deletions src/components/document/UpdateBanner.tsx

This file was deleted.

118 changes: 118 additions & 0 deletions src/components/document/UpdateToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Download, RefreshCw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { UpdaterPhase } from "@/hooks/useUpdater";

interface Props {
phase: UpdaterPhase;
pendingVersion?: string;
currentVersion?: string;
progressBytes?: number;
totalBytes?: number;
onInstall: () => void;
onDismiss: () => void;
}

const VISIBLE_PHASES: readonly UpdaterPhase[] = [
"available",
"downloading",
"installing",
"ready-to-relaunch",
];

export function UpdateToast({
phase,
pendingVersion,
currentVersion,
progressBytes,
totalBytes,
onInstall,
onDismiss,
}: Props) {
if (!VISIBLE_PHASES.includes(phase)) return null;

const isBusy = phase === "downloading" || phase === "installing";
const isRelaunching = phase === "ready-to-relaunch";
const percent = downloadPercent(progressBytes, totalBytes);

return (
<div className="fixed bottom-4 right-4 z-50 w-72 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="border bg-popover text-popover-foreground shadow-md">
<div className="flex items-center gap-2 px-2.5 py-2">
<Download className="size-3.5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold leading-tight">{title(phase)}</p>
<p className="truncate text-[11px] leading-tight text-muted-foreground">
{subtitle(phase, pendingVersion, currentVersion)}
</p>
</div>
{phase === "available" && (
<button
type="button"
onClick={onDismiss}
title="Dismiss until next version"
aria-label="Dismiss"
className="-mr-1 shrink-0 p-1 text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>

{phase === "downloading" && (
<div className="h-1 overflow-hidden bg-muted">
<div
className={`h-full bg-primary transition-all duration-300 ${percent === null ? "w-1/3 animate-pulse" : ""}`}
style={percent === null ? undefined : { width: `${percent}%` }}
/>
</div>
)}

<div className="flex items-center gap-1.5 border-t px-2.5 py-2">
<Button
size="sm"
className="h-7 flex-1 rounded-none px-2 text-xs"
onClick={onInstall}
disabled={isBusy || isRelaunching}
>
<RefreshCw className={`size-3.5${isBusy || isRelaunching ? " animate-spin" : ""}`} />
{phase === "downloading"
? percent === null
? "Downloading…"
: `Downloading ${percent}%`
: phase === "installing"
? "Installing…"
: isRelaunching
? "Restarting…"
: "Install and relaunch"}
</Button>
{phase === "available" && (
<Button size="sm" variant="ghost" className="h-7 rounded-none px-2 text-xs" onClick={onDismiss}>
Later
</Button>
)}
</div>
</div>
</div>
);
}

function title(phase: UpdaterPhase): string {
if (phase === "ready-to-relaunch") return "Restarting DocsReader";
if (phase === "installing") return "Installing update";
if (phase === "downloading") return "Downloading update";
return "Update available";
}

function subtitle(phase: UpdaterPhase, pendingVersion?: string, currentVersion?: string): string {
if (phase === "ready-to-relaunch") return "The app will reopen in a moment.";
if (phase === "downloading" || phase === "installing") {
return pendingVersion ? `DocsReader ${pendingVersion}` : "Please wait…";
}
const target = pendingVersion ? `DocsReader ${pendingVersion}` : "A new version";
return currentVersion ? `${target} · you're on v${currentVersion}` : `${target} is ready to install`;
}

function downloadPercent(progressBytes?: number, totalBytes?: number): number | null {
if (!totalBytes) return null;
return Math.min(100, Math.round(((progressBytes ?? 0) / totalBytes) * 100));
}
122 changes: 122 additions & 0 deletions src/components/settings/AboutSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Download, ExternalLink, RefreshCw } from "lucide-react";
import { getName } from "@tauri-apps/api/app";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Button } from "@/components/ui/button";
import type { UpdaterControls, UpdaterState } from "@/hooks/useUpdater";

const RELEASES_URL = "https://github.com/anbturki/docsreader/releases";

interface Props {
updater: UpdaterState & UpdaterControls;
}

export function AboutSection({ updater }: Props) {
const [appName, setAppName] = useState("DocsReader");

useEffect(() => {
getName()
.then(setAppName)
.catch(() => undefined);
}, []);

const { phase, currentVersion, pendingVersion, error, lastCheckedAt } = updater;
const isChecking = phase === "checking";
const isInstalling = phase === "downloading" || phase === "installing" || phase === "ready-to-relaunch";

return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center bg-primary/10 text-primary">
<Download className="size-6" />
</div>
<div>
<div className="text-base font-semibold">{appName}</div>
<div className="text-xs text-muted-foreground">
{currentVersion ? `Version ${currentVersion}` : "Version unknown"}
</div>
</div>
</div>

<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => void updater.checkNow()}
disabled={isChecking || isInstalling}
className="self-start"
>
<RefreshCw className={`size-4${isChecking ? " animate-spin" : ""}`} />
{isChecking ? "Checking…" : "Check for updates"}
</Button>
{phase === "available" && (
<Button type="button" onClick={() => void updater.install()} disabled={isInstalling}>
<Download className="size-4" />
Install v{pendingVersion}
</Button>
)}
</div>
<UpdateStatus
phase={phase}
pendingVersion={pendingVersion}
error={error}
lastCheckedAt={lastCheckedAt}
/>
</div>

<button
type="button"
onClick={() => void openUrl(RELEASES_URL)}
className="flex items-center gap-1.5 self-start text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="size-3.5" />
View release notes on GitHub
</button>
</div>
);
}

function UpdateStatus({
phase,
pendingVersion,
error,
lastCheckedAt,
}: {
phase: UpdaterState["phase"];
pendingVersion?: string;
error?: string;
lastCheckedAt?: number;
}) {
if (phase === "up-to-date") {
return (
<p className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-500">
<CheckCircle2 className="size-3.5" />
You're on the latest version.
</p>
);
}
if (phase === "available") {
return (
<p className="text-xs text-muted-foreground">
Version {pendingVersion} is available to download.
</p>
);
}
if (phase === "error" && error) {
return (
<p className="flex items-start gap-1.5 text-xs text-destructive">
<AlertCircle className="mt-px size-3.5 shrink-0" />
<span className="min-w-0 break-words">Couldn't check for updates: {error}</span>
</p>
);
}
if (lastCheckedAt) {
return (
<p className="text-xs text-muted-foreground">
Last checked {new Date(lastCheckedAt).toLocaleString()}.
</p>
);
}
return null;
}
10 changes: 9 additions & 1 deletion src/components/settings/SettingsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ beforeEach(() => {
vi.mocked(detectAgentClients).mockResolvedValue([]);
});

const noopUpdater = {
phase: "idle" as const,
install: vi.fn(),
dismiss: vi.fn(),
checkNow: vi.fn(),
};

function renderDialog() {
return render(
<SettingsDialog
Expand All @@ -24,6 +31,7 @@ function renderDialog() {
settings={defaultViewSettings}
onChange={vi.fn()}
onOpenWelcome={vi.fn()}
updater={noopUpdater}
/>
);
}
Expand All @@ -32,7 +40,7 @@ describe("SettingsDialog", () => {
it("opens on the appearance section with all nav entries", () => {
renderDialog();
expect(screen.getByText("Settings")).toBeInTheDocument();
for (const label of ["Appearance", "Reading", "Explorer", "AI agents", "Shortcuts"]) {
for (const label of ["Appearance", "Reading", "Explorer", "AI agents", "Shortcuts", "About"]) {
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
}
expect(screen.getByRole("radio", { name: "Light" })).toBeInTheDocument();
Expand Down
Loading
Loading