[In Progress] fix(ui): show toast error when package install/uninstall fails (#440)

* fix(ui): show toast error when package install/uninstall fails

HttpClient throws ApiError on HTTP 500 responses, but installPackage
and uninstallPackage hooks did not catch the exception. This caused
the error to propagate past the toast logic, so users saw no feedback
when a system package failed to install.

Wrap http.post calls in try/catch to always show toast with the server
error message. Update PackageSection handlers to check res.ok instead
of relying on try/catch for flow control.

* fix(ui): truncate verbose package error in toast notification

Extract last meaningful error line (e.g. "ERROR: ...") from package
manager output instead of dumping the full multi-line response into
the toast, which caused text overflow.

* fix(ui): wrap long text in toast instead of truncating

Add break-words and min-w-0 to toast content so long error messages
(URLs, package manager output) wrap properly within the toast element.
Remove shortError helper — show full error with proper wrapping.

* fix(ui): constrain toast container width to prevent overflow

Move max-w-sm from toast item to the fixed container so long text
(URLs, multi-line errors) is properly bounded. Add overflow-hidden
on toast items as a safety net.

* fix(pkg-helper): re-add packages cascade-removed by apk del

apk del removes orphaned dependencies, which may include other
user-installed packages if they were pulled in as transitive deps.
After uninstalling, check which persisted packages are still present
and re-install any that were accidentally removed.

* revert: remove cascade re-add logic from pkg-helper

apk del does not cascade-remove other world packages (verified via
Docker test). The re-add logic was based on incorrect assumption.
Root cause of the all-packages-deleted issue needs further investigation.
This commit is contained in:
Thieu Nguyen authored and GitHub committed 2026-03-24 21:15:22 +07:00
1 parent 00db30c2d1
commit 05c8b1f1dc
3 files changed
+42 -29

No files matched your search

+5 -5
View File
@@ -23,16 +23,16 @@ function ToastItem({ toast }: { toast: Toast }) {
return (
<div
className={cn(
"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-lg border p-4 shadow-lg backdrop-blur-sm animate-in slide-in-from-bottom-2 fade-in duration-200",
"pointer-events-auto flex w-full items-start gap-3 overflow-hidden rounded-lg border p-4 shadow-lg backdrop-blur-sm animate-in slide-in-from-bottom-2 fade-in duration-200",
styles[toast.variant],
)}
role="alert"
>
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="flex-1 space-y-0.5">
<p className="text-sm font-medium">{toast.title}</p>
<div className="flex-1 min-w-0 space-y-0.5">
<p className="text-sm font-medium break-words">{toast.title}</p>
{toast.message && (
<p className="text-xs opacity-80">{toast.message}</p>
<p className="text-xs opacity-80 break-words">{toast.message}</p>
)}
</div>
<button
@@ -51,7 +51,7 @@ export function Toaster() {
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col-reverse gap-2 pointer-events-none sm:bottom-6 sm:right-6 safe-bottom safe-right">
<div className="fixed bottom-4 right-4 z-[100] flex max-w-sm flex-col-reverse gap-2 pointer-events-none sm:bottom-6 sm:right-6 safe-bottom safe-right">
{toasts.slice(-3).map((t) => (
<ToastItem key={t.id} toast={t} />
))}
+29 -16
View File
@@ -21,6 +21,7 @@ interface InstallResult {
error: string;
}
export function usePackages() {
const http = useHttp();
const qc = useQueryClient();
@@ -36,27 +37,39 @@ export function usePackages() {
const refresh = useCallback(() => { refetch(); }, [refetch]);
const installPackage = useCallback(async (pkg: string, t: (key: string, opts?: Record<string, string>) => string) => {
const res = await http.post<InstallResult>("/v1/packages/install", { package: pkg });
if (res.ok) {
toast.success(t("messages.installSuccess", { name: pkg }));
qc.invalidateQueries({ queryKey: queryKeys.packages.all });
qc.invalidateQueries({ queryKey: queryKeys.packages.runtimes });
} else {
toast.error(t("messages.installError", { name: pkg }) + (res.error ? `: ${res.error}` : ""));
try {
const res = await http.post<InstallResult>("/v1/packages/install", { package: pkg });
if (res.ok) {
toast.success(t("messages.installSuccess", { name: pkg }));
qc.invalidateQueries({ queryKey: queryKeys.packages.all });
qc.invalidateQueries({ queryKey: queryKeys.packages.runtimes });
} else {
toast.error(t("messages.installError", { name: pkg }) + (res.error ? `: ${res.error}` : ""));
}
return res;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("messages.installError", { name: pkg }) + (msg ? `: ${msg}` : ""));
return { ok: false, error: msg } as InstallResult;
}
return res;
}, [http, qc]);
const uninstallPackage = useCallback(async (pkg: string, t: (key: string, opts?: Record<string, string>) => string) => {
const res = await http.post<InstallResult>("/v1/packages/uninstall", { package: pkg });
if (res.ok) {
toast.success(t("messages.uninstallSuccess", { name: pkg }));
qc.invalidateQueries({ queryKey: queryKeys.packages.all });
qc.invalidateQueries({ queryKey: queryKeys.packages.runtimes });
} else {
toast.error(t("messages.uninstallError", { name: pkg }) + (res.error ? `: ${res.error}` : ""));
try {
const res = await http.post<InstallResult>("/v1/packages/uninstall", { package: pkg });
if (res.ok) {
toast.success(t("messages.uninstallSuccess", { name: pkg }));
qc.invalidateQueries({ queryKey: queryKeys.packages.all });
qc.invalidateQueries({ queryKey: queryKeys.packages.runtimes });
} else {
toast.error(t("messages.uninstallError", { name: pkg }) + (res.error ? `: ${res.error}` : ""));
}
return res;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("messages.uninstallError", { name: pkg }) + (msg ? `: ${msg}` : ""));
return { ok: false, error: msg } as InstallResult;
}
return res;
}, [http, qc]);
return { packages: data, loading, refresh, installPackage, uninstallPackage };
+8 -8
View File
@@ -100,8 +100,8 @@ interface PackageSectionProps {
placeholder: string;
packages: PackageInfo[] | null | undefined;
loading: boolean;
onInstall: (pkg: string) => Promise<unknown>;
onUninstall: (pkg: string) => Promise<unknown>;
onInstall: (pkg: string) => Promise<{ ok: boolean }>;
onUninstall: (pkg: string) => Promise<{ ok: boolean }>;
}
function PackageSection({ title, placeholder, packages, loading, onInstall, onUninstall }: PackageSectionProps) {
@@ -115,12 +115,12 @@ function PackageSection({ title, placeholder, packages, loading, onInstall, onUn
const pkg = input.trim();
if (!pkg) return;
setInstallStatus("loading");
try {
await onInstall(pkg);
const res = await onInstall(pkg);
if (res.ok) {
setInstallStatus("success");
setInput("");
setTimeout(() => setInstallStatus("idle"), 2000);
} catch {
} else {
setInstallStatus("error");
setTimeout(() => setInstallStatus("idle"), 3000);
}
@@ -128,10 +128,10 @@ function PackageSection({ title, placeholder, packages, loading, onInstall, onUn
async function handleUninstall(name: string) {
setActionStatuses((s) => ({ ...s, [name]: "loading" }));
try {
await onUninstall(name);
const res = await onUninstall(name);
if (res.ok) {
setActionStatuses((s) => ({ ...s, [name]: "success" }));
} catch {
} else {
setActionStatuses((s) => ({ ...s, [name]: "error" }));
setTimeout(() => setActionStatuses((s) => ({ ...s, [name]: "idle" })), 3000);
}