feat(ui): implement global error toast and state management
Introduces a persistent, styled error toast notification system for displaying critical application errors. This required refactoring the local error handling logic in App.svelte to manage complex state objects instead of simple timers, allowing for better control over visibility and interaction (e.g., pausing on hover). - Adds global CSS styling and animations for the visible error toast component - Updates error auto-hide mechanism to use structured state management - Implements interactive features like pause/resume timing on mouse events
This commit is contained in:
+68
-14
@@ -191,6 +191,14 @@
|
|||||||
directoryName: string;
|
directoryName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ErrorAutoHideState {
|
||||||
|
timer?: ReturnType<typeof setTimeout>;
|
||||||
|
message: string;
|
||||||
|
remaining: number;
|
||||||
|
startedAt: number;
|
||||||
|
clearIfCurrent: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||||
const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
|
const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
|
||||||
@@ -364,7 +372,7 @@
|
|||||||
let updateCheckInFlight = false;
|
let updateCheckInFlight = false;
|
||||||
let updateDownloadTotal = 0;
|
let updateDownloadTotal = 0;
|
||||||
let updateDownloadedBytes = 0;
|
let updateDownloadedBytes = 0;
|
||||||
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
|
let errorAutoHideStates: Partial<Record<string, ErrorAutoHideState>> = {};
|
||||||
let commitPanelHeight = loadCommitPanelHeight();
|
let commitPanelHeight = loadCommitPanelHeight();
|
||||||
let resizingCommitPanel = false;
|
let resizingCommitPanel = false;
|
||||||
let resizeStartY = 0;
|
let resizeStartY = 0;
|
||||||
@@ -467,8 +475,8 @@
|
|||||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||||
Object.values(errorAutoHideTimers).forEach((timer) => {
|
Object.values(errorAutoHideStates).forEach((state) => {
|
||||||
if (timer) clearTimeout(timer);
|
if (state?.timer) clearTimeout(state.timer);
|
||||||
});
|
});
|
||||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||||
});
|
});
|
||||||
@@ -1110,18 +1118,39 @@
|
|||||||
message: string,
|
message: string,
|
||||||
clearIfCurrent: (message: string) => void,
|
clearIfCurrent: (message: string) => void,
|
||||||
) {
|
) {
|
||||||
const existing = errorAutoHideTimers[key];
|
const existing = errorAutoHideStates[key];
|
||||||
if (existing) {
|
if (existing?.timer) {
|
||||||
clearTimeout(existing);
|
clearTimeout(existing.timer);
|
||||||
delete errorAutoHideTimers[key];
|
|
||||||
}
|
}
|
||||||
|
delete errorAutoHideStates[key];
|
||||||
|
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
errorAutoHideTimers[key] = setTimeout(() => {
|
errorAutoHideStates[key] = {
|
||||||
clearIfCurrent(message);
|
message,
|
||||||
delete errorAutoHideTimers[key];
|
remaining: ERROR_AUTO_HIDE_MS,
|
||||||
}, ERROR_AUTO_HIDE_MS);
|
startedAt: Date.now(),
|
||||||
|
clearIfCurrent,
|
||||||
|
};
|
||||||
|
resumeAutoHideError(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseAutoHideError(key: string) {
|
||||||
|
const state = errorAutoHideStates[key];
|
||||||
|
if (!state?.timer) return;
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = undefined;
|
||||||
|
state.remaining = Math.max(0, state.remaining - (Date.now() - state.startedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeAutoHideError(key: string) {
|
||||||
|
const state = errorAutoHideStates[key];
|
||||||
|
if (!state || state.timer) return;
|
||||||
|
state.startedAt = Date.now();
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
state.clearIfCurrent(state.message);
|
||||||
|
delete errorAutoHideStates[key];
|
||||||
|
}, state.remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoKey(path: string): string {
|
function repoKey(path: string): string {
|
||||||
@@ -3798,14 +3827,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Status notices -->
|
<!-- Global error toast -->
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<section class="notice error" role="alert">
|
{#key errorMessage}
|
||||||
<AlertCircle size={17} aria-hidden="true" />
|
<div class="error-toast-region" aria-live="assertive" aria-atomic="true">
|
||||||
|
<section
|
||||||
|
class="error-toast"
|
||||||
|
role="alert"
|
||||||
|
onmouseenter={() => pauseAutoHideError("errorMessage")}
|
||||||
|
onmouseleave={() => resumeAutoHideError("errorMessage")}
|
||||||
|
>
|
||||||
|
<div class="error-toast-icon">
|
||||||
|
<AlertCircle size={20} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="error-toast-content">
|
||||||
|
<strong>{appLanguage === "de" ? "Etwas ist schiefgelaufen" : "Something went wrong"}</strong>
|
||||||
<span>{errorMessage}</span>
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="error-toast-close"
|
||||||
|
type="button"
|
||||||
|
onclick={() => { errorMessage = ""; }}
|
||||||
|
title={appLanguage === "de" ? "Schließen" : "Close"}
|
||||||
|
aria-label={appLanguage === "de" ? "Fehlermeldung schließen" : "Dismiss error"}
|
||||||
|
>
|
||||||
|
<X size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div class="error-toast-timeout" aria-hidden="true"></div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Status notices -->
|
||||||
{#if operation && operation !== "Opening repository" && !hasRepository}
|
{#if operation && operation !== "Opening repository" && !hasRepository}
|
||||||
<section class="notice busy" aria-live="polite">
|
<section class="notice busy" aria-live="polite">
|
||||||
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
||||||
|
|||||||
+124
@@ -1222,6 +1222,113 @@
|
|||||||
color: #eadfff;
|
color: #eadfff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Global error toast --- */
|
||||||
|
|
||||||
|
.error-toast-region {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--app-titlebar-height, 40px) + 14px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 90;
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 11px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 13px 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(232,96,96,0.46);
|
||||||
|
border-radius: 13px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(232,96,96,0.18), rgba(100,108,255,0.08)),
|
||||||
|
rgba(12,13,24,0.96);
|
||||||
|
box-shadow: 0 18px 54px rgba(0,0,0,0.46), 0 0 0 1px rgba(255,255,255,0.035) inset;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: error-toast-in 180ms cubic-bezier(.2,.8,.2,1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(232,96,96,0.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #ff9b9b;
|
||||||
|
background: rgba(232,96,96,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-content strong {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-content span {
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-close {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-close:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast-timeout {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #ff7878, #ef9a9a);
|
||||||
|
transform-origin: left;
|
||||||
|
animation: error-toast-timeout 6s linear both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-toast:hover .error-toast-timeout {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes error-toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(-10px) scale(.985); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes error-toast-timeout {
|
||||||
|
from { transform: scaleX(1); }
|
||||||
|
to { transform: scaleX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Update toast --- */
|
/* --- Update toast --- */
|
||||||
|
|
||||||
.update-toast {
|
.update-toast {
|
||||||
@@ -5306,6 +5413,23 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
background: rgba(210, 55, 75, 0.08);
|
background: rgba(210, 55, 75, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .error-toast {
|
||||||
|
border-color: rgba(190,48,66,0.28);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(210,55,75,0.11), rgba(49,95,214,0.05)),
|
||||||
|
rgba(255,255,255,0.96);
|
||||||
|
box-shadow: 0 18px 48px rgba(28,44,74,0.18), 0 0 0 1px rgba(255,255,255,0.8) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .error-toast-icon {
|
||||||
|
color: #b33445;
|
||||||
|
background: rgba(210,55,75,0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .error-toast-content strong {
|
||||||
|
color: #24314a;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .notice.busy {
|
:root[data-theme="light"] .notice.busy {
|
||||||
color: #315fd6;
|
color: #315fd6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user