feat: in-app update notification UI

Replace the old window.confirm updater with a proper React-based
update notification system:

- useUpdater hook now exposes state machine (idle → available →
  downloading → ready) and actions (startDownload, openReleaseNotes,
  dismiss)
- UpdateBanner component renders at the top of the app shell:
  - "Available" state: shows version, Release Notes link, Update Now
    button, dismiss X
  - "Downloading" state: animated spinner, progress bar with percentage
  - "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
  network errors or 404
- Release Notes link opens the GitHub Pages release history site

Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
  not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
  the app restarts (they may have unsaved work).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 10:44:31 +01:00
parent 62c52c7266
commit 4106bea670
4 changed files with 240 additions and 24 deletions

View File

@@ -46,3 +46,8 @@
.app__editor > * {
flex: 1;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -18,6 +18,7 @@ import { useEntryActions } from './hooks/useEntryActions'
import { isTauri } from './mock-tauri'
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'
import { useUpdater } from './hooks/useUpdater'
import { UpdateBanner } from './components/UpdateBanner'
import { setApiKey } from './utils/ai-chat'
import type { SidebarSelection, GitCommit } from './types'
import type { VaultOption } from './components/StatusBar'
@@ -139,7 +140,7 @@ function App() {
handleCloseTabRef: notes.handleCloseTabRef,
})
useUpdater()
const { status: updateStatus, actions: updateActions } = useUpdater()
useKeyboardNavigation({
tabs: notes.tabs,
@@ -168,6 +169,7 @@ function App() {
return (
<div className="app-shell">
<UpdateBanner status={updateStatus} actions={updateActions} />
<div className="app">
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={handleCreateNoteImmediate} onCreateNewType={openCreateTypeDialog} onCustomizeType={entryActions.handleCustomizeType} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />

View File

@@ -0,0 +1,145 @@
import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
import { restartApp } from '../hooks/useUpdater'
interface UpdateBannerProps {
status: UpdateStatus
actions: UpdateActions
}
export function UpdateBanner({ status, actions }: UpdateBannerProps) {
if (status.state === 'idle' || status.state === 'error') return null
return (
<div
data-testid="update-banner"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '6px 12px',
background: 'var(--accent-blue, #E8F0FE)',
borderBottom: '1px solid var(--border)',
fontSize: 13,
color: 'var(--foreground)',
flexShrink: 0,
}}
>
{status.state === 'available' && (
<>
<Download size={14} style={{ color: 'var(--primary)', flexShrink: 0 }} />
<span>
<strong>Laputa {status.version}</strong> is available
</span>
<button
data-testid="update-release-notes"
onClick={actions.openReleaseNotes}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 3,
background: 'none',
border: 'none',
color: 'var(--primary)',
cursor: 'pointer',
fontSize: 13,
padding: 0,
textDecoration: 'underline',
}}
>
Release Notes <ExternalLink size={11} />
</button>
<button
data-testid="update-now-btn"
onClick={actions.startDownload}
style={{
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--primary)',
color: '#fff',
border: 'none',
borderRadius: 5,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
}}
>
Update Now
</button>
<button
data-testid="update-dismiss"
onClick={actions.dismiss}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--muted-foreground)',
display: 'flex',
padding: 2,
}}
aria-label="Dismiss"
>
<X size={14} />
</button>
</>
)}
{status.state === 'downloading' && (
<>
<RefreshCw size={14} style={{ color: 'var(--primary)', flexShrink: 0, animation: 'spin 1s linear infinite' }} />
<span>Downloading Laputa {status.version}...</span>
<div
style={{
flex: 1,
maxWidth: 200,
height: 4,
background: 'var(--border)',
borderRadius: 2,
overflow: 'hidden',
}}
>
<div
data-testid="update-progress"
style={{
width: `${Math.round(status.progress * 100)}%`,
height: '100%',
background: 'var(--primary)',
borderRadius: 2,
transition: 'width 0.2s ease',
}}
/>
</div>
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>
{Math.round(status.progress * 100)}%
</span>
</>
)}
{status.state === 'ready' && (
<>
<RefreshCw size={14} style={{ color: 'var(--accent-green, #0F7B0F)', flexShrink: 0 }} />
<span>
<strong>Laputa {status.version}</strong> is ready restart to apply
</span>
<button
data-testid="update-restart-btn"
onClick={restartApp}
style={{
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--accent-green, #0F7B0F)',
color: '#fff',
border: 'none',
borderRadius: 5,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
}}
>
Restart Now
</button>
</>
)}
</div>
)
}

View File

@@ -1,40 +1,104 @@
import { useEffect } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { isTauri } from '../mock-tauri'
/**
* Checks for OTA updates on app startup (Tauri only).
* If an update is available, shows a native confirm dialog and
* downloads + installs + relaunches if the user accepts.
*/
export function useUpdater() {
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
export type UpdateStatus =
| { state: 'idle' }
| { state: 'available'; version: string; notes: string | undefined }
| { state: 'downloading'; version: string; progress: number }
| { state: 'ready'; version: string }
| { state: 'error' }
export interface UpdateActions {
startDownload: () => void
openReleaseNotes: () => void
dismiss: () => void
}
export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
const updateRef = useRef<unknown>(null)
useEffect(() => {
if (!isTauri()) return
const checkForUpdates = async () => {
try {
const { check } = await import('@tauri-apps/plugin-updater')
const { relaunch } = await import('@tauri-apps/plugin-process')
const update = await check()
if (!update) return
if (!update) return // up to date
const yes = window.confirm(
`A new version (${update.version}) is available.\n\n` +
(update.body ? `${update.body}\n\n` : '') +
'Do you want to update and restart now?'
)
if (!yes) return
await update.downloadAndInstall()
await relaunch()
} catch (err) {
// Silently log — update check failures should never block the app
console.warn('[updater] Failed to check for updates:', err)
updateRef.current = update
setStatus({
state: 'available',
version: update.version,
notes: update.body ?? undefined,
})
} catch {
// Network error or 404 — fail silently
console.warn('[updater] Failed to check for updates')
}
}
// Delay slightly so the app can render first
// Delay so the app can render first
const timer = setTimeout(checkForUpdates, 3000)
return () => clearTimeout(timer)
}, [])
const startDownload = useCallback(async () => {
const update = updateRef.current as {
version: string
downloadAndInstall: (cb: (event: { event: string; data?: { contentLength?: number; chunkLength?: number } }) => void) => Promise<void>
} | null
if (!update) return
let totalBytes = 0
let downloadedBytes = 0
setStatus({ state: 'downloading', version: update.version, progress: 0 })
try {
await update.downloadAndInstall((event) => {
if (event.event === 'Started' && event.data?.contentLength) {
totalBytes = event.data.contentLength
} else if (event.event === 'Progress' && event.data?.chunkLength) {
downloadedBytes += event.data.chunkLength
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1) : 0
setStatus({ state: 'downloading', version: update.version, progress })
} else if (event.event === 'Finished') {
setStatus({ state: 'ready', version: update.version })
}
})
// If Finished wasn't emitted via callback, set ready after await resolves
setStatus((prev) => (prev.state === 'downloading' ? { state: 'ready', version: update.version } : prev))
} catch {
console.warn('[updater] Download failed')
setStatus({ state: 'error' })
}
}, [])
const openReleaseNotes = useCallback(() => {
window.open(RELEASE_NOTES_URL, '_blank')
}, [])
const dismiss = useCallback(() => {
setStatus({ state: 'idle' })
}, [])
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
}
/**
* Trigger app restart after an update has been downloaded.
* Separated so the component can call it on button click.
*/
export async function restartApp(): Promise<void> {
try {
const { relaunch } = await import('@tauri-apps/plugin-process')
await relaunch()
} catch {
console.warn('[updater] Failed to relaunch')
}
}