Skip to content

Commit 5f54fd1

Browse files
committed
feat(ui): add update modal with changelog and brew command
1 parent 70635fa commit 5f54fd1

File tree

9 files changed

+583
-6
lines changed

9 files changed

+583
-6
lines changed

frontend/src/App.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState, lazy, Suspense, useCallback } from 'react';
2-
import { Toaster } from 'sonner';
2+
import { Toaster, toast } from 'sonner';
33

44
import { QueryClientProvider } from '@tanstack/react-query';
55
import { useTauri } from './hooks/useTauri';
@@ -11,6 +11,8 @@ import { ErrorBoundary } from './components/Common/ErrorBoundary';
1111
import { TooltipProvider } from './components/Common/Tooltip';
1212
import { ICONS } from './constants/icons';
1313
import { GenerationProvider } from './contexts/GenerationContext';
14+
import { useUpdateCheck } from './hooks/useUpdateCheck';
15+
import { UpdateModal } from './components/Common/UpdateModal';
1416

1517
import { SettingsPageSkeleton } from './components/Settings/SettingsPageSkeleton';
1618
import { ReviewViewSkeleton } from './components/Review/ReviewViewSkeleton';
@@ -59,8 +61,10 @@ type View = 'generate' | 'review' | 'repos' | 'rules' | 'learning' | 'settings';
5961
function App() {
6062
const [currentView, setCurrentView] = useState<View>('generate');
6163
const [error, setError] = useState<string | null>(null);
64+
const [showUpdateModal, setShowUpdateModal] = useState(false);
6265
const { parseDiff, getPendingReviewFromState, getDiffRequest, acquireDiffFromRequest } =
6366
useTauri();
67+
const { currentVersion, updateAvailable } = useUpdateCheck();
6468
const diffText = useAppStore(state => state.diffText);
6569
const setDiffText = useAppStore(state => state.setDiffText);
6670
const setParsedDiff = useAppStore(state => state.setParsedDiff);
@@ -115,6 +119,19 @@ function App() {
115119
};
116120
}, [loadDiff]);
117121

122+
useEffect(() => {
123+
if (updateAvailable) {
124+
toast('Update Available', {
125+
description: `LaReview v${updateAvailable.latestVersion} is available. You're on v${currentVersion}.`,
126+
duration: 10000,
127+
action: {
128+
label: 'Details',
129+
onClick: () => setShowUpdateModal(true),
130+
},
131+
});
132+
}
133+
}, [updateAvailable, currentVersion]);
134+
118135
const reviewViewMode = useAppStore(state => state.reviewViewMode);
119136

120137
const renderView = () => {
@@ -193,10 +210,24 @@ function App() {
193210
</div>
194211
)}
195212
<div className="flex flex-1 overflow-hidden">
196-
<Sidebar currentView={currentView} onViewChange={setCurrentView} />
213+
<Sidebar
214+
currentView={currentView}
215+
onViewChange={setCurrentView}
216+
currentVersion={currentVersion}
217+
updateAvailable={updateAvailable}
218+
onUpdateClick={() => setShowUpdateModal(true)}
219+
/>
197220
<main className="flex-1 overflow-hidden">{renderView()}</main>
198221
</div>
199222
</div>
223+
{updateAvailable && currentVersion && (
224+
<UpdateModal
225+
isOpen={showUpdateModal}
226+
onClose={() => setShowUpdateModal(false)}
227+
currentVersion={currentVersion}
228+
updateInfo={updateAvailable}
229+
/>
230+
)}
200231
</TooltipProvider>
201232
</GenerationProvider>
202233
</QueryClientProvider>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import React from 'react';
2+
import { toast } from 'sonner';
3+
import { ICONS } from '../../constants/icons';
4+
import { MarkdownRenderer } from '../ui/MarkdownRenderer';
5+
import { useTauri } from '../../hooks/useTauri';
6+
import type { UpdateInfo } from '../../hooks/useUpdateCheck';
7+
8+
const UPDATE_COMMAND = 'brew upgrade --cask lareview';
9+
10+
interface UpdateModalProps {
11+
isOpen: boolean;
12+
onClose: () => void;
13+
currentVersion: string;
14+
updateInfo: UpdateInfo;
15+
}
16+
17+
export const UpdateModal: React.FC<UpdateModalProps> = ({
18+
isOpen,
19+
onClose,
20+
currentVersion,
21+
updateInfo,
22+
}) => {
23+
const { copyToClipboard, openUrl } = useTauri();
24+
25+
if (!isOpen) return null;
26+
27+
const handleCopyCommand = async () => {
28+
await copyToClipboard(UPDATE_COMMAND);
29+
toast('Copied to clipboard', {
30+
description: 'Paste the command in your terminal to update.',
31+
});
32+
};
33+
34+
return (
35+
<div className="animate-in fade-in fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm duration-200">
36+
<div
37+
className="bg-bg-primary border-border/50 animate-in zoom-in-95 flex w-full max-w-lg flex-col rounded-xl border shadow-2xl duration-200"
38+
onClick={e => e.stopPropagation()}
39+
>
40+
{/* Header */}
41+
<div className="border-border/50 bg-bg-secondary/30 flex items-center justify-between rounded-t-xl border-b px-5 py-4">
42+
<div className="flex items-center gap-2.5">
43+
<div className="bg-brand/10 text-brand rounded-md p-1.5">
44+
<ICONS.ARROW_UP size={18} />
45+
</div>
46+
<h3 className="text-text-primary text-sm font-semibold">Update Available</h3>
47+
<span className="bg-brand/10 text-brand rounded-full px-2 py-0.5 text-[10px] font-medium">
48+
v{currentVersion} → v{updateInfo.latestVersion}
49+
</span>
50+
</div>
51+
<button
52+
onClick={onClose}
53+
className="text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary rounded p-1 transition-all"
54+
aria-label="Close"
55+
>
56+
<ICONS.ACTION_CLOSE size={18} />
57+
</button>
58+
</div>
59+
60+
{/* Body */}
61+
<div className="custom-scrollbar max-h-[50vh] overflow-y-auto p-5">
62+
{/* What's New */}
63+
<div className="mb-5">
64+
<h4 className="text-text-primary mb-2 text-xs font-semibold">What's New</h4>
65+
{updateInfo.releaseNotes ? (
66+
<MarkdownRenderer className="prose prose-invert prose-sm max-w-none">
67+
{updateInfo.releaseNotes}
68+
</MarkdownRenderer>
69+
) : (
70+
<p className="text-text-secondary text-xs italic">
71+
No release notes available for this version.
72+
</p>
73+
)}
74+
</div>
75+
76+
{/* How to Update */}
77+
<div>
78+
<h4 className="text-text-primary mb-2 text-xs font-semibold">How to Update</h4>
79+
<div className="bg-bg-tertiary border-border/50 flex items-center justify-between rounded-lg border px-3 py-2">
80+
<code className="text-text-primary text-xs">{UPDATE_COMMAND}</code>
81+
<button
82+
onClick={handleCopyCommand}
83+
className="text-text-tertiary hover:text-text-primary hover:bg-bg-secondary rounded p-1 transition-all"
84+
aria-label="Copy command"
85+
>
86+
<ICONS.ACTION_COPY size={14} />
87+
</button>
88+
</div>
89+
</div>
90+
</div>
91+
92+
{/* Footer */}
93+
<div className="border-border/50 flex items-center justify-between border-t px-5 py-4">
94+
<button
95+
onClick={() => openUrl(updateInfo.releaseUrl)}
96+
className="text-text-secondary hover:text-text-primary flex items-center gap-1.5 text-xs transition-colors"
97+
>
98+
<ICONS.ACTION_OPEN_WINDOW size={14} />
99+
<span>View on GitHub</span>
100+
</button>
101+
<button
102+
onClick={handleCopyCommand}
103+
className="bg-brand hover:bg-brand/90 text-brand-fg flex items-center gap-2 rounded-lg px-4 py-2 text-xs font-semibold transition-all active:scale-[0.98]"
104+
>
105+
<ICONS.ACTION_COPY size={14} />
106+
Copy Update Command
107+
</button>
108+
</div>
109+
</div>
110+
</div>
111+
);
112+
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { UpdateModal } from '../UpdateModal';
4+
import type { UpdateInfo } from '../../../hooks/useUpdateCheck';
5+
6+
const { mockCopyToClipboard, mockOpenUrl } = vi.hoisted(() => {
7+
const mockCopyToClipboard = vi.fn().mockResolvedValue(undefined);
8+
const mockOpenUrl = vi.fn().mockResolvedValue(undefined);
9+
return { mockCopyToClipboard, mockOpenUrl };
10+
});
11+
12+
vi.mock('../../../hooks/useTauri', () => ({
13+
useTauri: () => ({
14+
copyToClipboard: mockCopyToClipboard,
15+
openUrl: mockOpenUrl,
16+
}),
17+
}));
18+
19+
vi.mock('../../ui/MarkdownRenderer', () => ({
20+
MarkdownRenderer: ({ children }: { children: string }) => (
21+
<div data-testid="markdown-renderer">{children}</div>
22+
),
23+
}));
24+
25+
const defaultUpdateInfo: UpdateInfo = {
26+
latestVersion: '0.0.33',
27+
releaseUrl: 'https://github.com/puemos/lareview/releases/tag/v0.0.33',
28+
releaseName: 'v0.0.33',
29+
releaseNotes: '## Bug Fixes\n- Fixed a thing',
30+
};
31+
32+
const renderModal = (props?: Partial<Parameters<typeof UpdateModal>[0]>) =>
33+
render(
34+
<UpdateModal
35+
isOpen={true}
36+
onClose={vi.fn()}
37+
currentVersion="0.0.32"
38+
updateInfo={defaultUpdateInfo}
39+
{...props}
40+
/>
41+
);
42+
43+
describe('UpdateModal', () => {
44+
beforeEach(() => {
45+
mockCopyToClipboard.mockClear();
46+
mockOpenUrl.mockClear();
47+
});
48+
49+
it('does not render when isOpen is false', () => {
50+
renderModal({ isOpen: false });
51+
52+
expect(screen.queryByText('Update Available')).not.toBeInTheDocument();
53+
});
54+
55+
it('renders the version transition pill', () => {
56+
renderModal();
57+
58+
expect(screen.getByText('v0.0.32 → v0.0.33')).toBeInTheDocument();
59+
});
60+
61+
it('renders release notes via MarkdownRenderer', () => {
62+
renderModal();
63+
64+
const md = screen.getByTestId('markdown-renderer');
65+
expect(md).toHaveTextContent('## Bug Fixes');
66+
});
67+
68+
it('shows fallback when release notes are empty', () => {
69+
renderModal({
70+
updateInfo: { ...defaultUpdateInfo, releaseNotes: '' },
71+
});
72+
73+
expect(screen.getByText('No release notes available for this version.')).toBeInTheDocument();
74+
});
75+
76+
it('shows the brew upgrade command', () => {
77+
renderModal();
78+
79+
expect(screen.getByText('brew upgrade --cask lareview')).toBeInTheDocument();
80+
});
81+
82+
it('calls copyToClipboard when copy button is clicked', () => {
83+
renderModal();
84+
85+
fireEvent.click(screen.getByLabelText('Copy command'));
86+
87+
expect(mockCopyToClipboard).toHaveBeenCalledWith('brew upgrade --cask lareview');
88+
});
89+
90+
it('calls copyToClipboard when footer copy button is clicked', () => {
91+
renderModal();
92+
93+
fireEvent.click(screen.getByText('Copy Update Command'));
94+
95+
expect(mockCopyToClipboard).toHaveBeenCalledWith('brew upgrade --cask lareview');
96+
});
97+
98+
it('calls openUrl when View on GitHub is clicked', () => {
99+
renderModal();
100+
101+
fireEvent.click(screen.getByText('View on GitHub'));
102+
103+
expect(mockOpenUrl).toHaveBeenCalledWith(
104+
'https://github.com/puemos/lareview/releases/tag/v0.0.33'
105+
);
106+
});
107+
108+
it('calls onClose when close button is clicked', () => {
109+
const onClose = vi.fn();
110+
renderModal({ onClose });
111+
112+
fireEvent.click(screen.getByLabelText('Close'));
113+
114+
expect(onClose).toHaveBeenCalled();
115+
});
116+
});

frontend/src/components/Layout/Sidebar.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,23 @@ import type { ViewType } from '../../types';
99
import { useReviews } from '../../hooks/useReviews';
1010
import { useTauri } from '../../hooks/useTauri';
1111
import { queryKeys } from '../../lib/query-keys';
12+
import type { UpdateInfo } from '../../hooks/useUpdateCheck';
1213

1314
interface SidebarProps {
1415
currentView: ViewType;
1516
onViewChange: (view: ViewType) => void;
17+
currentVersion: string | null;
18+
updateAvailable: UpdateInfo | null;
19+
onUpdateClick?: () => void;
1620
}
1721

18-
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange }) => {
22+
export const Sidebar: React.FC<SidebarProps> = ({
23+
currentView,
24+
onViewChange,
25+
currentVersion,
26+
updateAvailable,
27+
onUpdateClick,
28+
}) => {
1929
const queryClient = useQueryClient();
2030
const { setReviewId, reviewId } = useAppStore();
2131
const { data: reviews = [], isLoading, invalidate } = useReviews();
@@ -189,6 +199,21 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onViewChange }) =
189199
ariaLabel="Navigate to Settings"
190200
/>
191201

202+
{currentVersion && (
203+
<div className="border-border/50 mx-2 flex items-center justify-between border-t px-2 pt-2 pb-1">
204+
<span className="text-text-disabled text-[10px]">v{currentVersion}</span>
205+
{updateAvailable && (
206+
<button
207+
onClick={() => onUpdateClick?.()}
208+
className="text-brand/70 hover:text-brand flex items-center gap-1 text-[10px] transition-colors"
209+
>
210+
<ICONS.ARROW_UP size={10} />
211+
<span>Update</span>
212+
</button>
213+
)}
214+
</div>
215+
)}
216+
192217
<ConfirmationModal
193218
isOpen={!!reviewToDelete}
194219
onClose={() => setReviewToDelete(null)}

frontend/src/components/Layout/__tests__/Sidebar.test.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { Sidebar } from '../Sidebar';
55
import { useAppStore } from '../../../store';
66

7-
const { mockGetReviewRuns, mockDeleteReview, mockUseTauri } = vi.hoisted(() => {
7+
const { mockGetReviewRuns, mockDeleteReview, mockOpenUrl, mockUseTauri } = vi.hoisted(() => {
88
const mockGetReviewRuns = vi.fn().mockResolvedValue([]);
99
const mockDeleteReview = vi.fn().mockResolvedValue(undefined);
10+
const mockOpenUrl = vi.fn().mockResolvedValue(undefined);
1011
const mockUseTauri = vi.fn(() => ({
1112
getReviewRuns: mockGetReviewRuns,
1213
deleteReview: mockDeleteReview,
14+
openUrl: mockOpenUrl,
1315
}));
14-
return { mockGetReviewRuns, mockDeleteReview, mockUseTauri };
16+
return { mockGetReviewRuns, mockDeleteReview, mockOpenUrl, mockUseTauri };
1517
});
1618

1719
vi.mock('../../../hooks/useTauri', async () => {
@@ -54,7 +56,12 @@ const renderSidebar = () => {
5456

5557
return render(
5658
<QueryClientProvider client={queryClient}>
57-
<Sidebar currentView="review" onViewChange={vi.fn()} />
59+
<Sidebar
60+
currentView="review"
61+
onViewChange={vi.fn()}
62+
currentVersion={null}
63+
updateAvailable={null}
64+
/>
5865
</QueryClientProvider>
5966
);
6067
};
@@ -64,6 +71,7 @@ describe('Sidebar', () => {
6471
useAppStore.getState().reset();
6572
mockGetReviewRuns.mockClear();
6673
mockDeleteReview.mockClear();
74+
mockOpenUrl.mockClear();
6775
mockUseTauri.mockClear();
6876
});
6977

0 commit comments

Comments
 (0)