diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6fd4d47..2cdaf63 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -64,6 +64,12 @@ import { resolveSelectedWorkspaceKey, resolveSelectedWorkspaceKeyId, } from "./lib/key-workspace-state"; +import { + buildInspectorEmptyStateMessage, + buildKeyBrowserEmptyStateMessage, + buildKeyBrowserStatusMessage, + resolveKeyBrowserSearchState, +} from "./lib/key-browser-search"; import { parseDemoTtlToSeconds, persistDemoKeyTtl, @@ -444,7 +450,9 @@ function App() { connections.find((connection) => connection.id === activeConnectionId) ?? connections[0]; - const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearch); + const searchState = resolveKeyBrowserSearchState(search); + const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch); + const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearchState.trimmedQuery); const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys; const draftErrors = validateConnectionDraft(connectionDraft, connections); const draftErrorList = Object.values(draftErrors); @@ -492,6 +500,22 @@ function App() { ? selectedValueRecord?.metadata.ttl.kind !== "persistent" : selectedKey?.ttl !== "persistent"); const latestConsoleEntry = consoleHistory[0] ?? null; + const keyBrowserStatusMessage = buildKeyBrowserStatusMessage({ + databaseLabel: activeDb, + visibleCount: filteredKeys.length, + hasMore: tauriAvailable && liveHasMore, + isLoading: isBrowseLoading, + search: deferredSearchState, + }); + const keyBrowserEmptyStateMessage = buildKeyBrowserEmptyStateMessage({ + databaseLabel: activeDb, + isLoading: isBrowseLoading, + search: deferredSearchState, + }); + const inspectorEmptyStateMessage = buildInspectorEmptyStateMessage( + activeDb, + deferredSearchState, + ); const qaSnapshot = buildQaSnapshot({ appName: bootstrap.app_name, appVersion, @@ -564,7 +588,7 @@ function App() { const request: RedisKeyBrowseRequest = { connection: toRedisConnectionRequest(activeConnection, activeDb), cursor: "0", - pattern: toRedisSearchPattern(deferredSearch), + pattern: deferredSearchState.requestPattern, page_size: browsePageSize, }; @@ -618,7 +642,7 @@ function App() { return () => { active = false; }; - }, [activeConnectionId, activeDb, deferredSearch, tauriAvailable]); + }, [activeConnectionId, activeDb, deferredSearchState.requestPattern, tauriAvailable]); useEffect(() => { if (!tauriAvailable) { @@ -1129,7 +1153,7 @@ function App() { request: { connection: toRedisConnectionRequest(activeConnection, activeDb), cursor: liveNextCursor, - pattern: toRedisSearchPattern(deferredSearch), + pattern: deferredSearchState.requestPattern, page_size: browsePageSize, } satisfies RedisKeyBrowseRequest, }); @@ -1732,7 +1756,25 @@ function App() { className="w-full bg-transparent text-sm text-[var(--ink-0)] outline-none placeholder:text-[var(--ink-muted)]" placeholder="Search key name or pattern" /> + {searchState.mode === "all" ? null : ( + + )} +
+
+ + {deferredSearchState.mode === "all" ? "all keys" : deferredSearchState.mode === "pattern" ? "pattern" : "contains"} + +

{keyBrowserStatusMessage}

+
+
@@ -1770,9 +1812,7 @@ function App() { {filteredKeys.length === 0 ? (
- {isBrowseLoading - ? `Loading keys from ${activeDb}...` - : `No keys match the current pattern in ${activeDb}.`} + {keyBrowserEmptyStateMessage}
) : null} @@ -1839,7 +1879,7 @@ function App() { ) ) : (
- No key matches the current pattern in {activeDb}. Clear or adjust the search to restore an inspector target. + {inspectorEmptyStateMessage}
)} diff --git a/apps/desktop/src/lib/key-browser-search.test.ts b/apps/desktop/src/lib/key-browser-search.test.ts new file mode 100644 index 0000000..0d248ed --- /dev/null +++ b/apps/desktop/src/lib/key-browser-search.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + buildInspectorEmptyStateMessage, + buildKeyBrowserEmptyStateMessage, + buildKeyBrowserStatusMessage, + resolveKeyBrowserSearchState, +} from "./key-browser-search"; + +describe("key browser search helpers", () => { + it("classifies blank, contains, and wildcard search input", () => { + expect(resolveKeyBrowserSearchState(" ")).toEqual({ + trimmedQuery: "", + requestPattern: null, + mode: "all", + }); + + expect(resolveKeyBrowserSearchState(" smoke ")).toEqual({ + trimmedQuery: "smoke", + requestPattern: "*smoke*", + mode: "contains", + }); + + expect(resolveKeyBrowserSearchState("smoke:*")).toEqual({ + trimmedQuery: "smoke:*", + requestPattern: "smoke:*", + mode: "pattern", + }); + }); + + it("builds a stable status message for visible counts and load state", () => { + expect( + buildKeyBrowserStatusMessage({ + databaseLabel: "db0", + visibleCount: 7, + hasMore: true, + isLoading: false, + search: resolveKeyBrowserSearchState("smoke"), + }), + ).toBe('7+ keys visible for contains search "smoke" in db0.'); + + expect( + buildKeyBrowserStatusMessage({ + databaseLabel: "db3", + visibleCount: 0, + hasMore: false, + isLoading: true, + search: resolveKeyBrowserSearchState("smoke:*"), + }), + ).toBe('Searching db3 for "smoke:*".'); + }); + + it("builds browser empty-state copy for blank and filtered results", () => { + expect( + buildKeyBrowserEmptyStateMessage({ + databaseLabel: "db0", + isLoading: false, + search: resolveKeyBrowserSearchState(""), + }), + ).toBe("No keys are visible in db0 yet."); + + expect( + buildKeyBrowserEmptyStateMessage({ + databaseLabel: "db2", + isLoading: false, + search: resolveKeyBrowserSearchState("audit:*"), + }), + ).toBe('No keys match "audit:*" in db2.'); + }); + + it("keeps inspector empty-state guidance aligned with browser search state", () => { + expect( + buildInspectorEmptyStateMessage("db1", resolveKeyBrowserSearchState("")), + ).toBe( + "No keys are visible in db1 yet. Browse another database or refresh after data arrives.", + ); + + expect( + buildInspectorEmptyStateMessage("db4", resolveKeyBrowserSearchState("jobs")), + ).toBe( + 'No keys match "jobs" in db4. Clear or adjust the search to restore an inspector target.', + ); + }); +}); diff --git a/apps/desktop/src/lib/key-browser-search.ts b/apps/desktop/src/lib/key-browser-search.ts new file mode 100644 index 0000000..661a185 --- /dev/null +++ b/apps/desktop/src/lib/key-browser-search.ts @@ -0,0 +1,100 @@ +export type KeyBrowserSearchMode = "all" | "contains" | "pattern"; + +export type KeyBrowserSearchState = { + trimmedQuery: string; + requestPattern: string | null; + mode: KeyBrowserSearchMode; +}; + +type KeyBrowserFeedbackContext = { + databaseLabel: string; + visibleCount: number; + hasMore: boolean; + isLoading: boolean; + search: KeyBrowserSearchState; +}; + +export function resolveKeyBrowserSearchState(value: string): KeyBrowserSearchState { + const trimmedQuery = value.trim(); + if (!trimmedQuery) { + return { + trimmedQuery: "", + requestPattern: null, + mode: "all", + }; + } + + if (/[*?\[\]]/.test(trimmedQuery)) { + return { + trimmedQuery, + requestPattern: trimmedQuery, + mode: "pattern", + }; + } + + return { + trimmedQuery, + requestPattern: `*${trimmedQuery}*`, + mode: "contains", + }; +} + +export function buildKeyBrowserStatusMessage({ + databaseLabel, + visibleCount, + hasMore, + isLoading, + search, +}: KeyBrowserFeedbackContext) { + if (isLoading) { + if (search.mode === "all") { + return `Loading keys from ${databaseLabel}.`; + } + + return `Searching ${databaseLabel} for ${formatSearchLabel(search)}.`; + } + + const visibleLabel = `${visibleCount}${hasMore ? "+" : ""}`; + + if (search.mode === "all") { + return `${visibleLabel} keys visible in ${databaseLabel}.`; + } + + const searchLabel = + search.mode === "pattern" + ? `pattern ${formatSearchLabel(search)}` + : `contains search ${formatSearchLabel(search)}`; + + return `${visibleLabel} keys visible for ${searchLabel} in ${databaseLabel}.`; +} + +export function buildKeyBrowserEmptyStateMessage({ + databaseLabel, + isLoading, + search, +}: Omit) { + if (isLoading) { + return `Loading keys from ${databaseLabel}...`; + } + + if (search.mode === "all") { + return `No keys are visible in ${databaseLabel} yet.`; + } + + return `No keys match ${formatSearchLabel(search)} in ${databaseLabel}.`; +} + +export function buildInspectorEmptyStateMessage( + databaseLabel: string, + search: KeyBrowserSearchState, +) { + if (search.mode === "all") { + return `No keys are visible in ${databaseLabel} yet. Browse another database or refresh after data arrives.`; + } + + return `No keys match ${formatSearchLabel(search)} in ${databaseLabel}. Clear or adjust the search to restore an inspector target.`; +} + +function formatSearchLabel(search: KeyBrowserSearchState) { + return `"${search.trimmedQuery}"`; +} diff --git a/plans/2026-03-31-hac-29-browse-search-baseline.md b/plans/2026-03-31-hac-29-browse-search-baseline.md new file mode 100644 index 0000000..33f102a --- /dev/null +++ b/plans/2026-03-31-hac-29-browse-search-baseline.md @@ -0,0 +1,47 @@ +# HAC-29 Browse And Search Baseline Stabilization + +Date: 2026-03-31 UTC +Owner: Senior Frontend Engineer +Issue: HAC-29 +Scope: `desktop-v1` browse/search hardening only + +## Context + +- `apps/desktop` already supports live browse pagination and DB-scoped search, but the visible search surface still leaves a few operator-facing gaps: + - no explicit feedback about whether input is treated as substring matching or raw Redis pattern matching + - no bounded clear action from the browser header + - one generic empty-state sentence was doing too much work across loading, empty DB, and filtered no-match cases +- Current phase must stay inside browse/search hardening and must not expand into Add Key, delete execution, non-string editing, or visual refresh work. + +## Decision + +- Keep the existing workspace layout and backend contracts unchanged. +- Extract search-state interpretation into a shared frontend helper so visible UI copy and backend browse requests use the same source of truth. +- Improve only the key-browser and inspector feedback layer: + - explicit search mode label (`all keys`, `contains`, `pattern`) + - concise result-status message under the search input + - clear action for returning to normal browse state + - distinct empty-state copy for loading, empty DB, and no-match search results + +## Implemented Slice + +1. Added `apps/desktop/src/lib/key-browser-search.ts` plus focused tests for: + - blank input vs contains search vs raw wildcard-pattern classification + - backend request-pattern generation + - browser status and empty-state copy + - inspector empty-state copy aligned to the same search state +2. Updated `apps/desktop/src/App.tsx` to: + - consume the shared search helper for live browse requests + - render a visible search-mode badge and status line under the browser search field + - expose a bounded clear action when search input is active + - keep browser and inspector empty-state feedback aligned +3. Updated `docs/frontend-workspace-baseline.md` so the shared acceptance language now includes the search-mode feedback, clear action, and differentiated empty states. + +## Verification + +- `pnpm --filter @redis-gui/desktop test` -> pass (9 files / 33 tests) +- `pnpm run desktop:build` -> pass + +## Remaining Risk + +- This environment still cannot launch a visible Tauri window, so final GUI-level browse/search smoke evidence remains a QA follow-up even though the frontend code and build path are green.