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:
@@ -46,3 +46,8 @@
|
||||
.app__editor > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
145
src/components/UpdateBanner.tsx
Normal file
145
src/components/UpdateBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user