1 Commits

Author SHA1 Message Date
Senior Frontend Engineer
bf6f8bc0bf feat(desktop): clarify key browser search states
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 08:02:18 +00:00
4 changed files with 278 additions and 8 deletions

View File

@@ -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 : (
<Button
variant="ghost"
size="sm"
onClick={() => setSearch("")}
className="shrink-0 px-2 py-1 text-xs"
>
Clear
</Button>
)}
</label>
<div className="flex items-center justify-between gap-3 text-xs text-[var(--ink-muted)]">
<div className="flex items-center gap-2">
<Badge variant="neutral" className="normal-case tracking-normal">
{deferredSearchState.mode === "all" ? "all keys" : deferredSearchState.mode === "pattern" ? "pattern" : "contains"}
</Badge>
<p>{keyBrowserStatusMessage}</p>
</div>
</div>
</div>
<div className="mt-4 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
@@ -1770,9 +1812,7 @@ function App() {
{filteredKeys.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-[var(--line-strong)] bg-[var(--surface-base)] px-4 py-8 text-center text-sm text-[var(--ink-soft)]">
{isBrowseLoading
? `Loading keys from ${activeDb}...`
: `No keys match the current pattern in ${activeDb}.`}
{keyBrowserEmptyStateMessage}
</div>
) : null}
@@ -1839,7 +1879,7 @@ function App() {
)
) : (
<div className="mt-3 flex h-64 items-center justify-center rounded-[22px] border border-dashed border-[var(--line-strong)] bg-[var(--surface-muted)] px-6 text-center text-sm text-[var(--ink-soft)]">
No key matches the current pattern in {activeDb}. Clear or adjust the search to restore an inspector target.
{inspectorEmptyStateMessage}
</div>
)}

View File

@@ -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.',
);
});
});

View File

@@ -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<KeyBrowserFeedbackContext, "visibleCount" | "hasMore">) {
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}"`;
}

View File

@@ -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.