diff --git a/src/App.tsx b/src/App.tsx index e21ec99..b81ef57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -536,6 +536,7 @@ function App() { onChange={viewSettings.update} initialSection={settingsSection} onOpenWelcome={() => void handleOpenWelcome()} + updater={updater} /> )} @@ -588,13 +589,12 @@ function App() { - diff --git a/src/components/document/UpdateBanner.tsx b/src/components/document/UpdateBanner.tsx deleted file mode 100644 index 70b2f6a..0000000 --- a/src/components/document/UpdateBanner.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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; - error?: string; - onInstall: () => void; - onDismiss: () => void; -} - -export function UpdateBanner({ - phase, - pendingVersion, - currentVersion, - progressBytes, - totalBytes, - error, - onInstall, - onDismiss, -}: Props) { - if (phase === "idle" || phase === "error") return null; - - const isBusy = phase === "downloading" || phase === "installing"; - const progressLabel = formatProgress(phase, progressBytes, totalBytes); - - return ( -
-
- -
- - {phase === "ready-to-relaunch" - ? "Restarting to apply update…" - : `DocsReader ${pendingVersion ?? ""} is available`} - - {currentVersion && phase === "available" ? ( - - You're on v{currentVersion} - - ) : null} - {progressLabel ? ( - {progressLabel} - ) : null} -
-
- - -
-
- {error ? ( -

{error}

- ) : null} -
- ); -} - -function formatProgress( - phase: UpdaterPhase, - progressBytes?: number, - totalBytes?: number -): string { - if (phase !== "downloading") return ""; - if (!totalBytes) { - if (!progressBytes) return ""; - return `${formatBytes(progressBytes)} downloaded`; - } - const pct = Math.min(100, Math.round(((progressBytes ?? 0) / totalBytes) * 100)); - return `${pct}% (${formatBytes(progressBytes ?? 0)} / ${formatBytes(totalBytes)})`; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} diff --git a/src/components/document/UpdateToast.tsx b/src/components/document/UpdateToast.tsx new file mode 100644 index 0000000..8527a13 --- /dev/null +++ b/src/components/document/UpdateToast.tsx @@ -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 ( +
+
+
+ +
+

{title(phase)}

+

+ {subtitle(phase, pendingVersion, currentVersion)} +

+
+ {phase === "available" && ( + + )} +
+ + {phase === "downloading" && ( +
+
+
+ )} + +
+ + {phase === "available" && ( + + )} +
+
+
+ ); +} + +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)); +} diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx new file mode 100644 index 0000000..f697707 --- /dev/null +++ b/src/components/settings/AboutSection.tsx @@ -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 ( +
+
+
+ +
+
+
{appName}
+
+ {currentVersion ? `Version ${currentVersion}` : "Version unknown"} +
+
+
+ +
+
+ + {phase === "available" && ( + + )} +
+ +
+ + +
+ ); +} + +function UpdateStatus({ + phase, + pendingVersion, + error, + lastCheckedAt, +}: { + phase: UpdaterState["phase"]; + pendingVersion?: string; + error?: string; + lastCheckedAt?: number; +}) { + if (phase === "up-to-date") { + return ( +

+ + You're on the latest version. +

+ ); + } + if (phase === "available") { + return ( +

+ Version {pendingVersion} is available to download. +

+ ); + } + if (phase === "error" && error) { + return ( +

+ + Couldn't check for updates: {error} +

+ ); + } + if (lastCheckedAt) { + return ( +

+ Last checked {new Date(lastCheckedAt).toLocaleString()}. +

+ ); + } + return null; +} diff --git a/src/components/settings/SettingsDialog.test.tsx b/src/components/settings/SettingsDialog.test.tsx index 42e7a80..60e84ff 100644 --- a/src/components/settings/SettingsDialog.test.tsx +++ b/src/components/settings/SettingsDialog.test.tsx @@ -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( ); } @@ -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(); diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx index c7ae63a..d58a2b1 100644 --- a/src/components/settings/SettingsDialog.tsx +++ b/src/components/settings/SettingsDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { BookOpen, Bot, FolderTree, Keyboard, Palette } from "lucide-react"; +import { BookOpen, Bot, FolderTree, Info, Keyboard, Palette } from "lucide-react"; import { Dialog, DialogContent, @@ -8,6 +8,8 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { ViewSettings } from "@/lib/storage"; +import type { UpdaterControls, UpdaterState } from "@/hooks/useUpdater"; +import { AboutSection } from "./AboutSection"; import { AgentsSection } from "./AgentsSection"; import { AppearanceSection } from "./AppearanceSection"; import { ExplorerSection } from "./ExplorerSection"; @@ -20,6 +22,7 @@ const SECTIONS = [ { id: "explorer", label: "Explorer", icon: FolderTree }, { id: "agents", label: "AI agents", icon: Bot }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, + { id: "about", label: "About", icon: Info }, ] as const; export type SettingsSection = (typeof SECTIONS)[number]["id"]; @@ -31,6 +34,7 @@ interface Props { onChange: (next: ViewSettings) => void; initialSection?: SettingsSection; onOpenWelcome: () => void; + updater: UpdaterState & UpdaterControls; } export default function SettingsDialog({ @@ -40,6 +44,7 @@ export default function SettingsDialog({ onChange, initialSection, onOpenWelcome, + updater, }: Props) { const [active, setActive] = useState(initialSection ?? "appearance"); @@ -95,6 +100,7 @@ export default function SettingsDialog({ {active === "shortcuts" && ( )} + {active === "about" && }
diff --git a/src/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index 7b5c857..0231695 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; +import { getVersion } from "@tauri-apps/api/app"; import { LazyStore } from "@tauri-apps/plugin-store"; const store = new LazyStore("docsreader.settings.json"); @@ -8,13 +9,17 @@ const DISMISSED_KEY = "updater.dismissedVersion"; const INITIAL_CHECK_DELAY_MS = 5_000; const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000; -export type UpdaterPhase = - | "idle" - | "available" - | "downloading" - | "installing" - | "ready-to-relaunch" - | "error"; +export const UPDATER_PHASES = [ + "idle", + "checking", + "available", + "downloading", + "installing", + "ready-to-relaunch", + "up-to-date", + "error", +] as const; +export type UpdaterPhase = (typeof UPDATER_PHASES)[number]; export interface UpdaterState { phase: UpdaterPhase; @@ -24,69 +29,99 @@ export interface UpdaterState { progressBytes?: number; totalBytes?: number; error?: string; + lastCheckedAt?: number; } export interface UpdaterControls { install: () => Promise; dismiss: () => Promise; + checkNow: () => Promise; } export function useUpdater(): UpdaterState & UpdaterControls { const [state, setState] = useState({ phase: "idle" }); const updateRef = useRef(null); const dismissedRef = useRef(null); + const checkingRef = useRef(false); - const runCheck = useCallback(async () => { + const runCheck = useCallback(async (manual: boolean) => { + if (checkingRef.current) return; + checkingRef.current = true; + // A manual check means the user wants to see updates now, even one they + // dismissed earlier. + if (manual) { + dismissedRef.current = null; + setState((prev) => ({ ...prev, phase: "checking", error: undefined })); + } try { const update = await check(); + const checkedAt = Date.now(); if (!update) { if (updateRef.current) { updateRef.current.close().catch(() => undefined); updateRef.current = null; } - setState({ phase: "idle" }); + setState((prev) => ({ + phase: manual ? "up-to-date" : "idle", + currentVersion: prev.currentVersion, + lastCheckedAt: checkedAt, + })); return; } - // Respect prior dismissal for the same version. A newer version - // supersedes the dismissal. - if (dismissedRef.current && dismissedRef.current === update.version) { + if (!manual && dismissedRef.current === update.version) { update.close().catch(() => undefined); + setState((prev) => ({ ...prev, lastCheckedAt: checkedAt })); return; } updateRef.current = update; - setState({ + setState((prev) => ({ phase: "available", pendingVersion: update.version, - currentVersion: update.currentVersion, + currentVersion: update.currentVersion ?? prev.currentVersion, notes: update.body, - }); + lastCheckedAt: checkedAt, + })); } catch (err) { - // Network / signature errors are non-fatal: keep the app usable. - // Surface only if no update was already detected. + // Never clobber an update that's already mid-flight. Background failures + // (offline, feed hiccup) stay silent; only a manual check surfaces them. setState((prev) => prev.phase === "available" || prev.phase === "downloading" || prev.phase === "installing" ? prev - : { phase: "error", error: err instanceof Error ? err.message : String(err) } + : { + phase: manual ? "error" : "idle", + currentVersion: prev.currentVersion, + lastCheckedAt: Date.now(), + error: manual ? (err instanceof Error ? err.message : String(err)) : undefined, + } ); + } finally { + checkingRef.current = false; } }, []); useEffect(() => { let cancelled = false; let interval: ReturnType | undefined; + let initial: ReturnType | undefined; (async () => { - dismissedRef.current = ((await store.get(DISMISSED_KEY)) ?? null); + try { + const version = await getVersion(); + if (!cancelled) { + setState((prev) => ({ ...prev, currentVersion: prev.currentVersion ?? version })); + } + } catch { + // Version is a nicety for the About panel; ignore if unavailable. + } + dismissedRef.current = (await store.get(DISMISSED_KEY)) ?? null; if (cancelled) return; - const initial = setTimeout(runCheck, INITIAL_CHECK_DELAY_MS); - interval = setInterval(runCheck, RECHECK_INTERVAL_MS); - return () => { - clearTimeout(initial); - }; + initial = setTimeout(() => void runCheck(false), INITIAL_CHECK_DELAY_MS); + interval = setInterval(() => void runCheck(false), RECHECK_INTERVAL_MS); })(); return () => { cancelled = true; + if (initial) clearTimeout(initial); if (interval) clearInterval(interval); if (updateRef.current) { updateRef.current.close().catch(() => undefined); @@ -138,8 +173,10 @@ export function useUpdater(): UpdaterState & UpdaterControls { updateRef.current.close().catch(() => undefined); updateRef.current = null; } - setState({ phase: "idle" }); + setState((prev) => ({ phase: "idle", currentVersion: prev.currentVersion, lastCheckedAt: prev.lastCheckedAt })); }, []); - return { ...state, install, dismiss }; + const checkNow = useCallback(() => runCheck(true), [runCheck]); + + return { ...state, install, dismiss, checkNow }; }