Skip to content

Commit 2d3eb5b

Browse files
committed
feat: harden updater failure UX and feedback reporting
1 parent 804e16a commit 2d3eb5b

18 files changed

+760
-42
lines changed

src/components/SimpleUpdateModal.tsx

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { Download, AlertTriangle, X, RotateCw } from 'lucide-react';
1212
import { useTranslation } from 'react-i18next';
1313
import { toast } from "sonner";
1414
import type { UseUpdaterReturn } from '@/hooks/useUpdater';
15+
import { useModal } from "@/contexts/modal";
1516
import { resolveUpdateErrorMessage } from '@/utils/updateError';
17+
import { buildUpdateDiagnostics } from '@/utils/updateDiagnostics';
1618

1719
interface SimpleUpdateModalProps {
1820
updater: UseUpdaterReturn;
@@ -22,6 +24,35 @@ interface SimpleUpdateModalProps {
2224
onSkipVersion: () => Promise<void> | void;
2325
}
2426

27+
function getTrimmedString(value: unknown): string | null {
28+
if (typeof value !== 'string') return null;
29+
const trimmed = value.trim();
30+
return trimmed.length > 0 ? trimmed : null;
31+
}
32+
33+
function resolveReleaseName(updateInfo: UseUpdaterReturn['state']['updateInfo']): string | null {
34+
if (!updateInfo) return null;
35+
36+
return (
37+
getTrimmedString(updateInfo.rawJson?.releaseName) ??
38+
getTrimmedString(updateInfo.rawJson?.name) ??
39+
getTrimmedString(updateInfo.rawJson?.title) ??
40+
null
41+
);
42+
}
43+
44+
function resolveReleaseNotes(updateInfo: UseUpdaterReturn['state']['updateInfo']): string | null {
45+
if (!updateInfo) return null;
46+
47+
return (
48+
getTrimmedString(updateInfo.body) ??
49+
getTrimmedString(updateInfo.rawJson?.notes) ??
50+
getTrimmedString(updateInfo.rawJson?.releaseNotes) ??
51+
getTrimmedString(updateInfo.rawJson?.changelog) ??
52+
null
53+
);
54+
}
55+
2556
export function SimpleUpdateModal({
2657
updater,
2758
isVisible,
@@ -30,11 +61,14 @@ export function SimpleUpdateModal({
3061
onSkipVersion,
3162
}: SimpleUpdateModalProps) {
3263
const { t } = useTranslation();
64+
const { openModal } = useModal();
3365

3466
if (!updater.state.hasUpdate) return null;
3567

3668
const currentVersion = updater.state.currentVersion;
3769
const newVersion = updater.state.newVersion || 'unknown';
70+
const releaseName = resolveReleaseName(updater.state.updateInfo);
71+
const releaseNotes = resolveReleaseNotes(updater.state.updateInfo);
3872

3973
const handleDownload = () => {
4074
void updater.downloadAndInstall();
@@ -64,6 +98,32 @@ export function SimpleUpdateModal({
6498
? resolveUpdateErrorMessage(updater.state.error, t)
6599
: null;
66100

101+
const handleReportIssue = () => {
102+
if (!updater.state.error) return;
103+
104+
const diagnostics = buildUpdateDiagnostics({
105+
error: updater.state.error,
106+
state: updater.state,
107+
});
108+
109+
const subject = t('simpleUpdateModal.reportIssueSubject', {
110+
currentVersion,
111+
newVersion,
112+
});
113+
const body = [t('simpleUpdateModal.reportIssuePrompt'), '', diagnostics].join('\n');
114+
115+
openModal("feedback", {
116+
feedbackPrefill: {
117+
feedbackType: "bug",
118+
subject,
119+
body,
120+
includeSystemInfo: true,
121+
},
122+
});
123+
124+
onClose();
125+
};
126+
67127
return (
68128
<Dialog open={isVisible} onOpenChange={onClose}>
69129
<DialogContent className="max-w-md">
@@ -90,6 +150,30 @@ export function SimpleUpdateModal({
90150
</div>
91151
</div>
92152

153+
{(releaseName || releaseNotes) && (
154+
<div className="space-y-1.5 p-2.5 bg-muted/40 border border-border/60 rounded-md">
155+
{releaseName && (
156+
<p className="text-[11px] text-muted-foreground" data-testid="update-release-name">
157+
<span className="font-medium">{t('simpleUpdateModal.releaseName')}</span>{' '}
158+
{releaseName}
159+
</p>
160+
)}
161+
{releaseNotes && (
162+
<div className="space-y-1">
163+
<p className="text-[11px] font-medium text-muted-foreground">
164+
{t('simpleUpdateModal.changes')}
165+
</p>
166+
<p
167+
className="text-xs text-foreground whitespace-pre-wrap max-h-28 overflow-y-auto pr-1 leading-relaxed"
168+
data-testid="update-release-notes"
169+
>
170+
{releaseNotes}
171+
</p>
172+
</div>
173+
)}
174+
</div>
175+
)}
176+
93177
{/* Restarting overlay */}
94178
{updater.state.isRestarting && (
95179
<div className="space-y-1.5">
@@ -106,7 +190,9 @@ export function SimpleUpdateModal({
106190
)}
107191

108192
{/* Download progress */}
109-
{updater.state.isDownloading && !updater.state.isRestarting && (
193+
{updater.state.isDownloading &&
194+
!updater.state.isInstalling &&
195+
!updater.state.isRestarting && (
110196
<div className="space-y-1.5">
111197
<div className="flex items-center gap-2 text-xs">
112198
<Download className="w-3.5 h-3.5 animate-bounce text-foreground" />
@@ -120,6 +206,18 @@ export function SimpleUpdateModal({
120206
variant="default"
121207
/>
122208
</div>
209+
)}
210+
211+
{/* Installing state */}
212+
{updater.state.isInstalling && !updater.state.isRestarting && (
213+
<div className="space-y-1.5">
214+
<div className="flex items-center gap-2 text-xs">
215+
<LoadingSpinner size="xs" variant="default" />
216+
<span className="text-muted-foreground">
217+
{t('simpleUpdateModal.installing')}
218+
</span>
219+
</div>
220+
</div>
123221
)}
124222

125223
{/* Error display */}
@@ -129,14 +227,29 @@ export function SimpleUpdateModal({
129227
<AlertTriangle className="w-3.5 h-3.5" />
130228
<span>{t('simpleUpdateModal.errorOccurred', { error: localizedError })}</span>
131229
</div>
230+
<p className="mt-2 text-[11px] text-muted-foreground">
231+
{t('simpleUpdateModal.failureGuide')}
232+
</p>
233+
<Button
234+
variant="outline"
235+
size="sm"
236+
className="mt-2 w-full"
237+
onClick={handleReportIssue}
238+
>
239+
{t('simpleUpdateModal.reportIssue')}
240+
</Button>
132241
</div>
133242
)}
134243
</div>
135244

136245
<DialogFooter className="flex-col gap-2">
137246
<Button
138247
onClick={handleDownload}
139-
disabled={updater.state.isDownloading || updater.state.isRestarting}
248+
disabled={
249+
updater.state.isDownloading ||
250+
updater.state.isInstalling ||
251+
updater.state.isRestarting
252+
}
140253
size="sm"
141254
className="w-full"
142255
>
@@ -145,6 +258,11 @@ export function SimpleUpdateModal({
145258
<RotateCw className="w-3.5 h-3.5 animate-spin" />
146259
{t('simpleUpdateModal.restartingShort')}
147260
</>
261+
) : updater.state.isInstalling ? (
262+
<>
263+
<LoadingSpinner size="xs" variant="default" />
264+
{t('simpleUpdateModal.installingShort')}
265+
</>
148266
) : updater.state.isDownloading ? (
149267
<>
150268
<LoadingSpinner size="xs" variant="default" />
@@ -163,7 +281,11 @@ export function SimpleUpdateModal({
163281
variant="outline"
164282
size="sm"
165283
onClick={() => void handleRemindLater()}
166-
disabled={updater.state.isDownloading || updater.state.isRestarting}
284+
disabled={
285+
updater.state.isDownloading ||
286+
updater.state.isInstalling ||
287+
updater.state.isRestarting
288+
}
167289
className="flex-1 text-xs"
168290
>
169291
{t('simpleUpdateModal.remindLater')}
@@ -172,7 +294,11 @@ export function SimpleUpdateModal({
172294
variant="outline"
173295
size="sm"
174296
onClick={() => void handleSkipVersion()}
175-
disabled={updater.state.isDownloading || updater.state.isRestarting}
297+
disabled={
298+
updater.state.isDownloading ||
299+
updater.state.isInstalling ||
300+
updater.state.isRestarting
301+
}
176302
className="flex-1 text-xs"
177303
>
178304
{t('simpleUpdateModal.skipVersion')}
@@ -181,7 +307,11 @@ export function SimpleUpdateModal({
181307
variant="outline"
182308
size="icon-sm"
183309
onClick={onClose}
184-
disabled={updater.state.isDownloading || updater.state.isRestarting}
310+
disabled={
311+
updater.state.isDownloading ||
312+
updater.state.isInstalling ||
313+
updater.state.isRestarting
314+
}
185315
aria-label={t('simpleUpdateModal.close')}
186316
>
187317
<X className="w-3.5 h-3.5" />

src/components/modals/feedback/FeedbackModal.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22
import { invoke } from "@tauri-apps/api/core";
33
import { useTranslation } from "react-i18next";
44
import { GithubIcon, MailIcon, InfoIcon } from "lucide-react";
5+
import type { FeedbackPrefill, FeedbackType } from "@/contexts/modal/context";
56
import {
67
Dialog,
78
DialogContent,
@@ -37,12 +38,13 @@ interface SystemInfo {
3738

3839
interface FeedbackModalProps {
3940
isOpen: boolean;
41+
prefill?: FeedbackPrefill | null;
4042
onClose: () => void;
4143
}
4244

43-
export const FeedbackModal = ({ isOpen, onClose }: FeedbackModalProps) => {
45+
export const FeedbackModal = ({ isOpen, prefill, onClose }: FeedbackModalProps) => {
4446
const { t } = useTranslation();
45-
const [feedbackType, setFeedbackType] = useState<string>("bug");
47+
const [feedbackType, setFeedbackType] = useState<FeedbackType>("bug");
4648
const [subject, setSubject] = useState<string>("");
4749
const [body, setBody] = useState<string>("");
4850
const [includeSystemInfo, setIncludeSystemInfo] = useState<boolean>(true);
@@ -54,7 +56,29 @@ export const FeedbackModal = ({ isOpen, onClose }: FeedbackModalProps) => {
5456
{ value: "feature", label: t("feedback.types.feature") },
5557
{ value: "improvement", label: t("feedback.types.improvement") },
5658
{ value: "other", label: t("feedback.types.other") },
57-
];
59+
] as const;
60+
61+
const handleFeedbackTypeChange = (value: string) => {
62+
if (
63+
value === "bug" ||
64+
value === "feature" ||
65+
value === "improvement" ||
66+
value === "other"
67+
) {
68+
setFeedbackType(value);
69+
}
70+
};
71+
72+
useEffect(() => {
73+
if (!isOpen || !prefill) return;
74+
75+
setFeedbackType(prefill.feedbackType ?? "bug");
76+
setSubject(prefill.subject ?? "");
77+
setBody(prefill.body ?? "");
78+
if (typeof prefill.includeSystemInfo === "boolean") {
79+
setIncludeSystemInfo(prefill.includeSystemInfo);
80+
}
81+
}, [isOpen, prefill]);
5882

5983
const loadSystemInfo = async () => {
6084
try {
@@ -143,7 +167,7 @@ export const FeedbackModal = ({ isOpen, onClose }: FeedbackModalProps) => {
143167
<div className="grid grid-cols-2 gap-3">
144168
<div className="space-y-1.5">
145169
<Label htmlFor="feedbackType" className="text-xs">{t("feedback.type")}</Label>
146-
<Select value={feedbackType} onValueChange={setFeedbackType}>
170+
<Select value={feedbackType} onValueChange={handleFeedbackTypeChange}>
147171
<SelectTrigger id="feedbackType" className="h-8 text-xs">
148172
<SelectValue />
149173
</SelectTrigger>

src/components/modals/feedback/FeedbackModalContainer.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ import { FeedbackModal } from "./FeedbackModal";
22
import { useModal } from "@/contexts/modal";
33

44
export const FeedbackModalContainer: React.FC = () => {
5-
const { isOpen, closeModal } = useModal();
5+
const { isOpen, closeModal, feedbackPrefill } = useModal();
66

77
if (!isOpen("feedback")) return null;
88

9-
return <FeedbackModal isOpen={true} onClose={() => closeModal("feedback")} />;
9+
return (
10+
<FeedbackModal
11+
isOpen={true}
12+
prefill={feedbackPrefill}
13+
onClose={() => closeModal("feedback")}
14+
/>
15+
);
1016
};

0 commit comments

Comments
 (0)