1 Commits

Author SHA1 Message Date
Senior Frontend Engineer
a9524dfbf6 feat(desktop): unify frontend error notices
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 11:08:01 +00:00
9 changed files with 825 additions and 96 deletions

View File

@@ -32,6 +32,7 @@ Redis GUI 的产品基础工程,采用 Rust workspace + Tauri desktop 结构
```bash
pnpm install
cargo test -p redis-core
pnpm --filter @redis-gui/desktop test
pnpm run desktop:dev
pnpm run desktop:build
pnpm run desktop:tauri:dev
@@ -43,6 +44,7 @@ pnpm run desktop:tauri:build
- 仓库现在同时补齐了 `pnpm-workspace.yaml` 和根 `package.json``packageManager`,将 `pnpm` 作为桌面开发与打包的规范入口。
- 仓库通过 `.npmrc` 固定安装 devDependencies避免宿主机的 `NODE_ENV=production` 破坏桌面构建链路。
- `cargo test -p redis-core` 默认验证共享后端核心,包括 mock Redis 握手与命令执行测试,避免在缺少桌面系统库时阻断后端迭代。
- `pnpm --filter @redis-gui/desktop test` 运行桌面前端的最小单测基线;当前覆盖共享错误映射层,避免后续功能迭代把后端错误码重新退回成零散文案拼接。
- `pnpm run desktop:dev` 启动浏览器态工作台壳层,用于检查布局、交互和 demo fallback此模式下没有 Tauri 命令桥。
- `pnpm run desktop:tauri:dev` 启动带 Tauri 后端桥的桌面工作台;连接测试和命令执行会调用现有 `test_redis_connection``execute_redis_command` 命令,因此需要完整的 Tauri Linux 运行依赖。
- `pnpm run desktop:tauri:build` 会先执行桌面前端 build再进入 Tauri/Rust 的真实打包流程;当前 Linux `.deb``.rpm` 产物位于仓库根 `target/release/bundle/`
@@ -78,6 +80,7 @@ pnpm run desktop:tauri:dev
- 首版可见界面现在已接入真实的桌面 browse / inspect / string save / TTL update 路径,但仍保持危险操作和完整产品化流程在后续阶段演进。
- 当前前端消费 `backend_bootstrap``test_redis_connection``browse_redis_keys``read_redis_value``write_redis_value``execute_redis_command`;浏览器态仍保持明确的 demo fallback。
- 当前前端还通过共享错误映射层统一消费 `redis-core` 的稳定错误码使连接测试、浏览、读取、保存、TTL 和命令执行失败都落到一致的操作提示上。
- `inspect_redis_key``update_redis_key_ttl` 已在桌面桥可用;当前 UI 已暴露 TTL 设置与移除控制,但删除执行仍保持未开放状态。
- 连接测试、key browse、value inspector 和命令托盘在 Tauri 桌面运行时走真实后端桥;浏览器态 `pnpm run desktop:dev` 会明确回退到 demo 数据,不伪装成真实 Redis 行为。
- 具体 UI/UX 边界与 QA 验收入口见 `docs/frontend-workspace-baseline.md``docs/qa/acceptance-matrix.md`

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite --host 0.0.0.0 --port 1420",
"build": "vite build",
"test": "vitest run",
"tauri": "tauri"
},
"dependencies": {
@@ -23,6 +24,7 @@
"@tauri-apps/cli": "^2.1.0",
"@vitejs/plugin-react": "^5.0.2",
"tailwindcss": "^4.1.12",
"vite": "^7.1.0"
"vite": "^7.1.0",
"vitest": "^3.2.4"
}
}

View File

@@ -37,6 +37,14 @@ import {
} from "./components/ui/dialog";
import { Input } from "./components/ui/input";
import { Textarea } from "./components/ui/textarea";
import {
toBackendError,
type BackendErrorContext,
createAccentNotice,
createNeutralNotice,
toBackendErrorNotice,
type OperatorNotice,
} from "./lib/operator-notices";
import { cn } from "./lib/utils";
declare const __APP_VERSION__: string;
@@ -107,12 +115,6 @@ type BackendBootstrap = {
next_backend_milestone: string;
};
type BackendError = {
code: string;
message: string;
detail: string | null;
};
type RedisConnectionRequest = {
target: ConnectionProfile["target"];
password: string | null;
@@ -375,7 +377,11 @@ function App() {
const [consoleInput, setConsoleInput] = useState("GET cache:homepage");
const [consoleHistory, setConsoleHistory] = useState(initialConsoleHistory);
const [bootstrap, setBootstrap] = useState(fallbackBootstrap);
const [banner, setBanner] = useState("String edits stay local to the shell until the backend write contract is wired.");
const [banner, setBanner] = useState<OperatorNotice>(
createNeutralNotice(
"String edits stay local to the shell until the backend write contract is wired.",
),
);
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isConnectionTestRunning, setIsConnectionTestRunning] = useState(false);
@@ -403,6 +409,10 @@ function App() {
const appVersion = __APP_VERSION__;
const buildMode = import.meta.env.DEV ? "dev" : "production";
const runtimeMode = tauriAvailable ? "live desktop bridge" : "browser demo fallback";
const showNeutralBanner = (message: string) => setBanner(createNeutralNotice(message));
const showAccentBanner = (message: string) => setBanner(createAccentNotice(message));
const showBackendErrorBanner = (error: unknown, context: BackendErrorContext) =>
setBanner(toBackendErrorNotice(error, context));
const activeConnection =
connections.find((connection) => connection.id === activeConnectionId) ??
@@ -467,7 +477,7 @@ function App() {
`latestCommand=${latestConsoleEntry?.command ?? "none"}`,
`latestCommandSource=${latestConsoleEntry?.source ?? "none"}`,
`latestCommandStatus=${latestConsoleEntry?.status ?? "none"}`,
`banner=${banner}`,
`banner=${banner.message}`,
].join("\n");
useEffect(() => {
@@ -558,7 +568,6 @@ function App() {
return;
}
const backendError = toBackendError(error);
startTransition(() => {
setLiveKeys([]);
setLiveNextCursor("0");
@@ -568,7 +577,11 @@ function App() {
setDraftValue("");
setTtlDraft("");
setIsBrowseLoading(false);
setBanner(`Live key browse failed on ${activeConnection.name} / ${activeDb}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "key_browse",
connectionName: activeConnection.name,
databaseLabel: activeDb,
});
});
});
@@ -634,13 +647,17 @@ function App() {
return;
}
const backendError = toBackendError(error);
startTransition(() => {
setSelectedValueRecord(null);
setDraftValue("");
setTtlDraft("");
setIsInspectorLoading(false);
setBanner(`Live value read failed for ${selectedKey.name} on ${activeConnection.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "value_read",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
});
@@ -665,7 +682,9 @@ function App() {
setActiveConnectionId(connection.id);
setActiveDb(formatDatabaseLabel(connection.target.database));
setSelectedKeyId(null);
setBanner(`Switched workspace context to ${connection.name} on ${formatDatabaseLabel(connection.target.database)}.`);
showNeutralBanner(
`Switched workspace context to ${connection.name} on ${formatDatabaseLabel(connection.target.database)}.`,
);
});
};
@@ -688,13 +707,13 @@ function App() {
: connection,
),
);
setBanner(`Scoped the workspace to ${database} for ${activeConnection.name}.`);
showNeutralBanner(`Scoped the workspace to ${database} for ${activeConnection.name}.`);
});
};
const handleCreateDraftConnection = () => {
if (draftErrorList.length > 0) {
setBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
showNeutralBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
return;
}
@@ -721,19 +740,23 @@ function App() {
setActiveConnectionId(nextConnection.id);
setActiveDb(formatDatabaseLabel(nextDatabase));
setSelectedKeyId(null);
setBanner("A draft connection profile was added to the shell. Credentials remain memory-only.");
showAccentBanner(
"A draft connection profile was added to the shell. Credentials remain memory-only.",
);
setConnectionDialogOpen(false);
});
};
const handleSaveString = async () => {
if (!selectedKey) {
setBanner(`Select a key in ${activeDb} before attempting a shell-level save.`);
showNeutralBanner(`Select a key in ${activeDb} before attempting a shell-level save.`);
return;
}
if (!tauriAvailable) {
setBanner(`Saved mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is not wired yet.`);
showNeutralBanner(
`Saved mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is not wired yet.`,
);
return;
}
@@ -763,13 +786,19 @@ function App() {
: formatRedisValueData(result.data),
);
setIsSaveRunning(false);
setBanner(`Saved live string value for ${selectedKey.name} in ${activeDb} and preserved the current TTL boundary.`);
showAccentBanner(
`Saved live string value for ${selectedKey.name} in ${activeDb} and preserved the current TTL boundary.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsSaveRunning(false);
setBanner(`Live string save failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "value_write",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
@@ -780,23 +809,27 @@ function App() {
}
setDeleteDialogOpen(false);
setBanner(`Delete confirmation completed for ${selectedKey.name} in ${activeDb}. Mutation stays mocked until a delete contract is approved.`);
showNeutralBanner(
`Delete confirmation completed for ${selectedKey.name} in ${activeDb}. Mutation stays mocked until a delete contract is approved.`,
);
};
const handleApplyTtl = async () => {
if (!selectedKey) {
setBanner(`Select a key in ${activeDb} before attempting a TTL update.`);
showNeutralBanner(`Select a key in ${activeDb} before attempting a TTL update.`);
return;
}
const validationError = validateTtlDraft(ttlDraft);
if (validationError) {
setBanner(`TTL update is invalid: ${validationError}`);
showNeutralBanner(`TTL update is invalid: ${validationError}`);
return;
}
if (!tauriAvailable) {
setBanner(`Browser dev mode keeps TTL changes in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL update.`);
showNeutralBanner(
`Browser dev mode keeps TTL changes in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL update.`,
);
return;
}
@@ -824,25 +857,33 @@ function App() {
: "",
);
setIsTtlUpdateRunning(false);
setBanner(`Updated live TTL for ${selectedKey.name} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${activeDb}.`);
showAccentBanner(
`Updated live TTL for ${selectedKey.name} to ${formatRedisKeyTtl(result.metadata.ttl)} in ${activeDb}.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsTtlUpdateRunning(false);
setBanner(`Live TTL update failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "ttl_update",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
const handlePersistTtl = async () => {
if (!selectedKey) {
setBanner(`Select a key in ${activeDb} before attempting to remove TTL.`);
showNeutralBanner(`Select a key in ${activeDb} before attempting to remove TTL.`);
return;
}
if (!tauriAvailable) {
setBanner(`Browser dev mode keeps TTL removal in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL change.`);
showNeutralBanner(
`Browser dev mode keeps TTL removal in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL change.`,
);
return;
}
@@ -865,25 +906,35 @@ function App() {
syncUpdatedMetadata(result.metadata);
setTtlDraft("");
setIsTtlUpdateRunning(false);
setBanner(`Removed TTL for ${selectedKey.name}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`);
showAccentBanner(
`Removed TTL for ${selectedKey.name}; the key is now ${formatRedisKeyTtl(result.metadata.ttl)}.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsTtlUpdateRunning(false);
setBanner(`Removing TTL failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "ttl_remove",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
const handleRefreshInspector = async () => {
if (!selectedKey) {
setBanner(`No key is selected in ${activeDb}. Adjust or clear the current search pattern first.`);
showNeutralBanner(
`No key is selected in ${activeDb}. Adjust or clear the current search pattern first.`,
);
return;
}
if (!tauriAvailable) {
setBanner(`Refreshed shell metadata for ${selectedKey.name} in ${activeDb}. Live value reload remains downstream of backend browse contracts.`);
showNeutralBanner(
`Refreshed shell metadata for ${selectedKey.name} in ${activeDb}. Live value reload remains downstream of backend browse contracts.`,
);
return;
}
@@ -912,13 +963,19 @@ function App() {
);
setTtlDraft(result.metadata.ttl.kind === "expires_in_millis" ? String(result.metadata.ttl.value) : "");
setIsInspectorLoading(false);
setBanner(`Refreshed live metadata and value surface for ${selectedKey.name} in ${activeDb}.`);
showAccentBanner(
`Refreshed live metadata and value surface for ${selectedKey.name} in ${activeDb}.`,
);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsInspectorLoading(false);
setBanner(`Live metadata refresh failed for ${selectedKey.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "inspector_refresh",
connectionName: activeConnection.name,
databaseLabel: activeDb,
keyName: selectedKey.name,
});
});
}
};
@@ -926,17 +983,21 @@ function App() {
const handleCopyQaSnapshot = async () => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
setCopyFeedback("unavailable");
setBanner("Clipboard export is unavailable in this runtime. Copy the QA snapshot text manually from the side rail.");
showNeutralBanner(
"Clipboard export is unavailable in this runtime. Copy the QA snapshot text manually from the side rail.",
);
return;
}
try {
await navigator.clipboard.writeText(qaSnapshot);
setCopyFeedback("copied");
setBanner("Copied the current QA snapshot to the clipboard for smoke evidence.");
showAccentBanner("Copied the current QA snapshot to the clipboard for smoke evidence.");
} catch {
setCopyFeedback("unavailable");
setBanner("Clipboard export failed in this runtime. Copy the QA snapshot text manually from the side rail.");
showNeutralBanner(
"Clipboard export failed in this runtime. Copy the QA snapshot text manually from the side rail.",
);
}
};
@@ -981,10 +1042,13 @@ function App() {
setIsBrowseLoading(false);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setIsBrowseLoading(false);
setBanner(`Loading more keys failed on ${activeConnection.name} / ${activeDb}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "key_browse",
connectionName: activeConnection.name,
databaseLabel: activeDb,
});
});
}
};
@@ -1006,7 +1070,9 @@ function App() {
: item,
),
);
setBanner(`Testing ${connection.name} against ${activeDb} using the current desktop contract.`);
showNeutralBanner(
`Testing ${connection.name} against ${activeDb} using the current desktop contract.`,
);
});
if (!tauriAvailable) {
@@ -1022,7 +1088,9 @@ function App() {
: item,
),
);
setBanner(`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`);
showNeutralBanner(
`Tauri runtime is unavailable in browser dev mode, so connection testing stayed in demo fallback for ${connection.name}.`,
);
setIsConnectionTestRunning(false);
});
return;
@@ -1051,12 +1119,12 @@ function App() {
),
);
setActiveDb(formatDatabaseLabel(result.selected_database));
setBanner(`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`);
showAccentBanner(
`Live Redis test passed for ${connection.name} on ${formatDatabaseLabel(result.selected_database)}. Round-trip status: ${result.round_trip_status}.`,
);
setIsConnectionTestRunning(false);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setConnections((current) =>
current.map((item) =>
@@ -1064,12 +1132,16 @@ function App() {
? {
...item,
status: "error",
detail: backendError.code,
detail: formatBackendErrorCodeLabel(toBackendError(error).code),
}
: item,
),
);
setBanner(`Live Redis test failed for ${connection.name}: ${formatBackendError(backendError)}.`);
showBackendErrorBanner(error, {
operation: "connection_test",
connectionName: connection.name,
databaseLabel: activeDb,
});
setIsConnectionTestRunning(false);
});
}
@@ -1102,7 +1174,9 @@ function App() {
},
...current.slice(0, 4),
]);
setBanner(`Blocked ${command.toUpperCase()} in ${activeDb}. Administrative commands remain outside the visible shell.`);
showNeutralBanner(
`Blocked ${command.toUpperCase()} in ${activeDb}. Administrative commands remain outside the visible shell.`,
);
});
return;
}
@@ -1122,7 +1196,9 @@ function App() {
},
...current.slice(0, 4),
]);
setBanner(`Tauri runtime is unavailable in browser dev mode, so ${command.toUpperCase()} ran against the visible demo data only.`);
showNeutralBanner(
`Tauri runtime is unavailable in browser dev mode, so ${command.toUpperCase()} ran against the visible demo data only.`,
);
setConsoleInput(trimmed);
setIsCommandRunning(false);
});
@@ -1148,24 +1224,34 @@ function App() {
},
...current.slice(0, 4),
]);
setBanner(`Live Redis command completed on ${activeConnection.name} / ${formatDatabaseLabel(result.database)}.`);
showAccentBanner(
`Live Redis command completed on ${activeConnection.name} / ${formatDatabaseLabel(result.database)}.`,
);
setConsoleInput(trimmed);
setIsCommandRunning(false);
});
} catch (error) {
const backendError = toBackendError(error);
startTransition(() => {
setConsoleHistory((current) => [
{
command: trimmed,
output: formatBackendError(backendError),
output: toBackendErrorNotice(error, {
operation: "command_execution",
connectionName: activeConnection.name,
databaseLabel: activeDb,
commandName: command,
}).message,
status: "error",
source: "live",
},
...current.slice(0, 4),
]);
setBanner(`Live Redis command failed on ${activeConnection.name} / ${activeDb}: ${backendError.message}.`);
showBackendErrorBanner(error, {
operation: "command_execution",
connectionName: activeConnection.name,
databaseLabel: activeDb,
commandName: command,
});
setConsoleInput(trimmed);
setIsCommandRunning(false);
});
@@ -1219,8 +1305,28 @@ function App() {
</div>
</header>
<div className="mb-4 rounded-[24px] border border-[var(--line-soft)] bg-[var(--surface-raised)] px-4 py-3 shadow-[var(--shadow-panel)]">
<p className="text-sm text-[var(--ink-soft)]">{banner}</p>
<div
className={cn(
"mb-4 rounded-[24px] border px-4 py-3 shadow-[var(--shadow-panel)]",
banner.tone === "danger"
? "border-[var(--danger-soft)] bg-[var(--danger-faint)]"
: banner.tone === "accent"
? "border-[var(--accent-strong)] bg-[var(--accent-faint)]"
: "border-[var(--line-soft)] bg-[var(--surface-raised)]",
)}
>
<p
className={cn(
"text-sm",
banner.tone === "danger"
? "text-[var(--danger-strong)]"
: banner.tone === "accent"
? "text-[var(--accent-strong)]"
: "text-[var(--ink-soft)]",
)}
>
{banner.message}
</p>
</div>
<main className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[310px_minmax(360px,1.1fr)_minmax(420px,1fr)]">
@@ -1902,6 +2008,10 @@ function formatConnectionCardDetail(connection: ConnectionProfile) {
return connection.detail;
}
function formatBackendErrorCodeLabel(code: string) {
return code.replace(/_/g, " ");
}
function toRedisConnectionRequest(
connection: ConnectionProfile,
activeDb: string,
@@ -1984,37 +2094,6 @@ function validateConnectionDraft(
return errors;
}
function toBackendError(error: unknown): BackendError {
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
) {
return {
code:
"code" in error && typeof error.code === "string"
? error.code
: "internal",
message: error.message,
detail:
"detail" in error && typeof error.detail === "string"
? error.detail
: null,
};
}
return {
code: "internal",
message: error instanceof Error ? error.message : "Unknown backend error.",
detail: null,
};
}
function formatBackendError(error: BackendError) {
return error.detail ? `${error.message} (${error.detail})` : error.message;
}
function formatRedisResponse(response: RedisResponse) {
const normalized = normalizeRedisResponse(response);
return typeof normalized === "string"

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
createAccentNotice,
createNeutralNotice,
toBackendError,
toBackendErrorNotice,
} from "./operator-notices";
describe("operator notices", () => {
it("creates explicit accent notices for successful actions", () => {
expect(createAccentNotice("Saved")).toEqual({
tone: "accent",
message: "Saved",
});
});
it("normalizes unknown thrown values into internal backend errors", () => {
expect(toBackendError(new Error("boom"))).toEqual({
code: "internal",
message: "boom",
detail: null,
});
});
it("maps authentication failures to a consistent operator-facing notice", () => {
expect(
toBackendErrorNotice(
{
code: "authentication_failed",
message: "Authentication failed.",
detail: "WRONGPASS invalid username-password pair",
},
{
operation: "connection_test",
connectionName: "redis-local-fixture",
databaseLabel: "db0",
},
),
).toEqual({
tone: "danger",
message:
"Redis rejected the credentials used while testing redis-local-fixture on db0. Verify username, password, and ACL scope for the selected connection. Backend detail: Authentication failed. (WRONGPASS invalid username-password pair).",
});
});
it("keeps unsupported operations in a non-destructive neutral notice", () => {
expect(
toBackendErrorNotice(
{
code: "unsupported_operation",
message: "Only string replacement is supported in V1.",
detail: null,
},
{
operation: "value_write",
connectionName: "redis-local-fixture",
databaseLabel: "db0",
keyName: "session:42",
},
),
).toEqual(
createNeutralNotice(
"The current desktop contract does not allow saving session:42 on redis-local-fixture / db0 yet. Stay on the visible read-only path or wait for a backend contract change before retrying. Backend detail: Only string replacement is supported in V1.",
),
);
});
it("describes command failures with command and scope context", () => {
expect(
toBackendErrorNotice(
{
code: "command_failed",
message: "ERR unknown command 'FOOO'",
detail: null,
},
{
operation: "command_execution",
connectionName: "redis-local-fixture",
databaseLabel: "db0",
commandName: "fooo",
},
),
).toEqual({
tone: "danger",
message:
"Redis returned an error while running FOOO on redis-local-fixture / db0. Adjust the Redis input first; retrying the same command unchanged is unlikely to help. Backend detail: ERR unknown command 'FOOO'.",
});
});
});

View File

@@ -0,0 +1,205 @@
export type NoticeTone = "neutral" | "accent" | "danger";
export type OperatorNotice = {
tone: NoticeTone;
message: string;
};
export type BackendErrorCode =
| "invalid_connection_config"
| "unsupported_tls_mode"
| "unsupported_operation"
| "connection_failed"
| "authentication_failed"
| "command_failed"
| "internal";
export type BackendError = {
code: BackendErrorCode;
message: string;
detail: string | null;
};
export type BackendErrorContext =
| {
operation: "connection_test";
connectionName: string;
databaseLabel: string;
}
| {
operation: "key_browse";
connectionName: string;
databaseLabel: string;
}
| {
operation: "value_read" | "value_write" | "ttl_update" | "ttl_remove" | "inspector_refresh";
connectionName: string;
databaseLabel: string;
keyName: string;
}
| {
operation: "command_execution";
connectionName: string;
databaseLabel: string;
commandName: string;
};
export function createNeutralNotice(message: string): OperatorNotice {
return {
tone: "neutral",
message,
};
}
export function createAccentNotice(message: string): OperatorNotice {
return {
tone: "accent",
message,
};
}
export function createDangerNotice(message: string): OperatorNotice {
return {
tone: "danger",
message,
};
}
export function toBackendError(error: unknown): BackendError {
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
) {
return {
code: normalizeBackendErrorCode("code" in error ? error.code : undefined),
message: error.message,
detail:
"detail" in error && typeof error.detail === "string"
? error.detail
: null,
};
}
return {
code: "internal",
message: error instanceof Error ? error.message : "Unknown backend error.",
detail: null,
};
}
export function toBackendErrorNotice(
error: unknown,
context: BackendErrorContext,
): OperatorNotice {
const backendError = toBackendError(error);
const tone =
backendError.code === "invalid_connection_config" ||
backendError.code === "unsupported_operation" ||
backendError.code === "unsupported_tls_mode"
? "neutral"
: "danger";
const headline = buildHeadline(backendError, context);
const hint = buildHint(backendError.code, context);
const detail = buildTechnicalDetail(backendError);
return {
tone,
message: [headline, hint, detail].filter(Boolean).join(" "),
};
}
function normalizeBackendErrorCode(value: unknown): BackendErrorCode {
switch (value) {
case "invalid_connection_config":
case "unsupported_tls_mode":
case "unsupported_operation":
case "connection_failed":
case "authentication_failed":
case "command_failed":
case "internal":
return value;
default:
return "internal";
}
}
function buildHeadline(error: BackendError, context: BackendErrorContext) {
const target = describeContext(context);
switch (error.code) {
case "invalid_connection_config":
return `The request for ${target} was rejected before Redis was contacted.`;
case "unsupported_tls_mode":
return `The request for ${target} requires TLS handling that this foundation build does not support yet.`;
case "unsupported_operation":
return `The current desktop contract does not allow ${target} yet.`;
case "connection_failed":
return `Redis could not be reached while ${target}.`;
case "authentication_failed":
return `Redis rejected the credentials used while ${target}.`;
case "command_failed":
return `Redis returned an error while ${target}.`;
case "internal":
return `The desktop bridge failed while ${target}.`;
default:
return `The desktop bridge failed while ${target}.`;
}
}
function buildHint(code: BackendErrorCode, context: BackendErrorContext) {
switch (code) {
case "invalid_connection_config":
return "Check host, port, database, search pattern, and key input before retrying.";
case "unsupported_tls_mode":
return "Keep TLS disabled in this foundation build unless product scope explicitly adds transport controls.";
case "unsupported_operation":
return "Stay on the visible read-only path or wait for a backend contract change before retrying.";
case "connection_failed":
return `Verify that ${context.connectionName} is reachable from this machine and that the selected database is available.`;
case "authentication_failed":
return "Verify username, password, and ACL scope for the selected connection.";
case "command_failed":
return "Adjust the Redis input first; retrying the same command unchanged is unlikely to help.";
case "internal":
return "Retry once, then capture the QA snapshot and escalate if the same failure repeats.";
default:
return "";
}
}
function buildTechnicalDetail(error: BackendError) {
const detail = error.detail && error.detail !== error.message ? error.detail : null;
const summary = detail ? `${error.message} (${detail})` : error.message;
return `Backend detail: ${ensureTrailingPeriod(summary)}`;
}
function describeContext(context: BackendErrorContext) {
switch (context.operation) {
case "connection_test":
return `testing ${context.connectionName} on ${context.databaseLabel}`;
case "key_browse":
return `loading keys from ${context.connectionName} / ${context.databaseLabel}`;
case "value_read":
return `reading ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
case "value_write":
return `saving ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
case "ttl_update":
return `updating TTL for ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
case "ttl_remove":
return `removing TTL from ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
case "inspector_refresh":
return `refreshing ${context.keyName} on ${context.connectionName} / ${context.databaseLabel}`;
case "command_execution":
return `running ${context.commandName.toUpperCase()} on ${context.connectionName} / ${context.databaseLabel}`;
default:
return "handling a desktop Redis request";
}
}
function ensureTrailingPeriod(value: string) {
return /[.!?]$/.test(value) ? value : `${value}.`;
}

View File

@@ -89,6 +89,7 @@ Use a four-zone workspace:
- Tauri desktop mode now allows live connection tests, live key browsing, live value inspection, string save for supported keys, live TTL updates, and live command execution while keeping browser dev mode on explicit demo fallback.
- The visible shell now validates draft-connection name, host, port, and DB input before creation, and prevents duplicate profile names inside the local shell state.
- Zero-result key searches now place the inspector into an explicit empty state instead of leaving a stale key visible.
- Frontend error handling now routes stable backend error codes through a shared operator-facing notice layer so live failures render with consistent copy and banner tone across connection, browse, inspect, write, TTL, and command flows.
## QA Acceptance For This Milestone
@@ -100,6 +101,7 @@ Use a four-zone workspace:
- When search returns zero keys, the inspector switches to an empty state instead of showing the last selected key.
- The connection action distinguishes desktop live testing from browser demo fallback.
- Invalid draft connections cannot be created from the visible shell.
- Live backend failures are shown through a consistent banner treatment instead of per-surface ad hoc copy.
- Non-string keys are explicitly read-only.
- String keys show a save affordance near the value surface.
- TTL controls stay beside the inspector metadata and expose both set and remove flows.
@@ -113,6 +115,7 @@ Use:
```bash
pnpm install
pnpm --filter @redis-gui/desktop test
pnpm run desktop:dev
```
@@ -120,4 +123,5 @@ Notes:
- `pnpm run desktop:dev` is the browser shell path. It supports layout review and demo fallback only.
- `pnpm run desktop:tauri:dev` is the desktop integration path. Use it when QA or backend needs to validate the existing command bridge against a live Redis target.
- `pnpm --filter @redis-gui/desktop test` should stay green before handing frontend foundation changes to QA or other feature teams.
- Use `pnpm run desktop:build` for a production bundle check before handing the shell to QA or integration work.

View File

@@ -21,12 +21,12 @@ Owner: QA Engineer
| macOS app smoke | Same as Linux, using signed or documented macOS artifact | Same failure paths, plus first-run permission or gatekeeper guidance if relevant | Build artifact name, install steps, screenshots, QA run log | Blocked: no validated macOS build or artifact evidence |
| Windows app smoke | Same as Linux, using installer or portable package | Same failure paths, plus Windows-specific install and uninstall validation | Build artifact name, install steps, screenshots, QA run log | Blocked: no validated Windows build or artifact evidence |
| Desktop workspace shell | Rail, browser, inspector, and command tray render together with active connection, build identity, runtime mode, and a copyable QA snapshot visible; command history labels live vs demo results | Empty search state clears the inspector selection, read-only non-string value state, destructive confirmation, and browser-mode demo fallback stay stable and explicit | `npm run desktop:dev` or `npm run desktop:build`, `docs/frontend-workspace-baseline.md`, screenshots or copied QA snapshot text | Pass for shell-level evidence; not a substitute for Redis feature acceptance |
| Connection management | Create a validated draft profile, keep DB context visible, and run the existing live connection-test bridge from Tauri desktop mode | Invalid host, bad port, bad password, duplicate name, timeout, and unreachable server are handled with clear messaging | Tauri run log, screenshots, repeatable steps, Redis target | Partially blocked: the shell now validates draft name/host/port/DB locally and calls the real connection-test command, and a seeded Redis smoke path exists, but profile persistence and full evidence capture are not complete |
| Key browsing | Browse DBs and keys, paginate or virtualize large lists, view metadata | Empty DB, deleted key during browse, permission error, and connection drop do not crash UI | Seed dataset, result screenshots, observed limits | Partially blocked: desktop UI now calls the live browse bridge with `SCAN` pagination and metadata, but fixture-backed Tauri screenshots and measured limits are still pending |
| Connection management | Create a validated draft profile, keep DB context visible, and run the existing live connection-test bridge from Tauri desktop mode | Invalid host, bad port, bad password, duplicate name, timeout, and unreachable server are handled with consistent operator-facing messaging | Tauri run log, screenshots, repeatable steps, Redis target | Partially blocked: the shell now validates draft name/host/port/DB locally, maps backend error codes into shared notices, and calls the real connection-test command, but profile persistence and full evidence capture are not complete |
| Key browsing | Browse DBs and keys, paginate or virtualize large lists, view metadata | Empty DB, deleted key during browse, permission error, and connection drop do not crash UI, and live failures reuse the shared error banner treatment | Seed dataset, result screenshots, observed limits | Partially blocked: desktop UI now calls the live browse bridge with `SCAN` pagination and metadata, but fixture-backed Tauri screenshots and measured limits are still pending |
| Search | Search by key name or pattern and return matching set within documented limits | No matches, invalid pattern, and very large result sets are handled predictably | Seed dataset, search queries, measured behavior | Partially blocked: desktop UI now maps search to live browse requests, but fixture-backed evidence and measured limits are still pending |
| Value editing | Open supported value types, edit, save, and refresh persisted value | Concurrent modification, invalid payload, and permission error preserve user context and surface failure | Before/after values, screenshots, repeatable steps | Partially blocked: string read/save/refresh is wired through the live desktop bridge and non-string types stay explicitly read only, but fixture-backed evidence and broader editing scope remain pending |
| TTL operations | View TTL, set TTL, remove TTL, and verify expiration behavior | Invalid TTL, already expired key, and permission error handled safely | Seed dataset, timestamps, verification steps | Partially blocked: desktop UI now exposes live TTL set and remove controls through the existing contract, but seeded-fixture validation evidence is still missing |
| Command console | Execute safe Redis commands in Tauri desktop mode and show result or error output with explicit live/demo labeling | Invalid command, long-running command, and blocked dangerous command are handled predictably | Command transcript, screenshots, allow or deny rules | Partially blocked: the desktop UI now calls the real command execution bridge, but it is not yet validated against a shared live Redis fixture and dangerous mutations still stay blocked at shell level |
| Value editing | Open supported value types, edit, save, and refresh persisted value | Concurrent modification, invalid payload, and permission error preserve user context and surface failure through the shared operator notice layer | Before/after values, screenshots, repeatable steps | Partially blocked: string read/save/refresh is wired through the live desktop bridge and non-string types stay explicitly read only, but fixture-backed evidence and broader editing scope remain pending |
| TTL operations | View TTL, set TTL, remove TTL, and verify expiration behavior | Invalid TTL, already expired key, and permission error handled safely with consistent banner feedback | Seed dataset, timestamps, verification steps | Partially blocked: desktop UI now exposes live TTL set and remove controls through the existing contract, but seeded-fixture validation evidence is still missing |
| Command console | Execute safe Redis commands in Tauri desktop mode and show result or error output with explicit live/demo labeling | Invalid command, long-running command, and blocked dangerous command are handled predictably, and backend failures reuse the shared operator notice mapping | Command transcript, screenshots, allow or deny rules | Partially blocked: the desktop UI now calls the real command execution bridge, but it is not yet validated against a shared live Redis fixture and dangerous mutations still stay blocked at shell level |
| Dangerous operation confirmation | Delete, flush, overwrite, or destructive commands require explicit confirmation | Cancel path does not mutate data; confirm path mutates only intended scope | Confirmation UX screenshots, before/after data | Blocked: feature not implemented |
| Installer and packaging | Package metadata, icon, version, and uninstall behavior are correct on each target OS | Corrupt install, upgrade, downgrade, and missing dependency behavior are documented or handled | Artifact checklist, install logs, uninstall evidence | Partially blocked: Linux `.deb` and `.rpm` artifacts now exist, but AppImage failed and install or uninstall evidence is still absent |

View File

@@ -0,0 +1,32 @@
# HAC-17 Frontend Foundation Hardening
Date: 2026-03-28
Owner: Senior Frontend Engineer
Issue: HAC-17
## Goal
Harden the desktop frontend foundation for upcoming iterations without changing backend contracts or expanding product scope.
## Selected Scope
- extract a shared frontend error mapping layer that translates stable backend error codes into consistent operator-facing copy
- route connection test, key browse, value read, string save, TTL update, inspector refresh, and command execution failures through the shared mapper
- add the minimum test baseline around the new error translation logic so future feature work can extend it safely
- update shared run and acceptance docs so QA can verify the new error behavior
## Non-Goals
- no backend contract change
- no new Redis capability surface
- no visual redesign outside the existing desktop shell language
- no delete execution or broader write support
## Verification Plan
- `pnpm --filter @redis-gui/desktop test`
- `pnpm run desktop:build`
## Notes
- The existing shell already exposes stable backend error codes from `redis-core`; the current gap is inconsistent UI handling rather than missing backend semantics.

315
pnpm-lock.yaml generated
View File

@@ -50,6 +50,9 @@ importers:
vite:
specifier: ^7.1.0
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
vitest:
specifier: ^3.2.4
version: 3.2.4(jiti@2.6.1)(lightningcss@1.32.0)
packages:
@@ -805,6 +808,12 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -814,10 +823,43 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
baseline-browser-mapping@2.10.11:
resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==}
engines: {node: '>=6.0.0'}
@@ -828,9 +870,21 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
caniuse-lite@1.0.30001781:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -850,6 +904,10 @@ packages:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -864,6 +922,9 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
@@ -873,6 +934,13 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -905,6 +973,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -989,6 +1060,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1011,6 +1085,13 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1077,10 +1158,22 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@@ -1091,10 +1184,28 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1124,6 +1235,11 @@ packages:
'@types/react':
optional: true
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1164,6 +1280,39 @@ packages:
yaml:
optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1698,6 +1847,13 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@vitejs/plugin-react@5.2.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))':
@@ -1712,10 +1868,54 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
baseline-browser-mapping@2.10.11: {}
browserslist@4.28.1:
@@ -1726,8 +1926,20 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
cac@6.7.14: {}
caniuse-lite@1.0.30001781: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
check-error@2.1.3: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -1740,6 +1952,8 @@ snapshots:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -1751,6 +1965,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.2
es-module-lexer@1.7.0: {}
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
@@ -1782,6 +1998,12 @@ snapshots:
escalade@3.2.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
expect-type@1.3.0: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1799,6 +2021,8 @@ snapshots:
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
jsesc@3.1.0: {}
json5@2.2.3: {}
@@ -1852,6 +2076,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
loupe@3.2.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -1870,6 +2096,10 @@ snapshots:
node-releases@2.0.36: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -1945,19 +2175,39 @@ snapshots:
semver@6.3.1: {}
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
tailwind-merge@3.5.0: {}
tailwindcss@4.2.2: {}
tapable@2.3.2: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
tslib@2.8.1: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
@@ -1977,6 +2227,27 @@ snapshots:
react: 19.2.4
tslib: 2.8.1
vite-node@3.2.4(jiti@2.6.1)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0):
dependencies:
esbuild: 0.27.4
@@ -1990,4 +2261,48 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.32.0
vitest@3.2.4(jiti@2.6.1)(lightningcss@1.32.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
vite-node: 3.2.4(jiti@2.6.1)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
yallist@3.1.1: {}