Compare commits
2 Commits
cmp-18-typ
...
cmp-29-bro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6f8bc0bf | ||
|
|
291f744e0f |
@@ -37,6 +37,13 @@ import {
|
||||
} from "./components/ui/dialog";
|
||||
import { Input } from "./components/ui/input";
|
||||
import { Textarea } from "./components/ui/textarea";
|
||||
import {
|
||||
isLocalSmokeFixtureConnection,
|
||||
localSmokeFixtureCommandPresets,
|
||||
localSmokeFixtureExpectedKeys,
|
||||
localSmokeFixtureProfileId,
|
||||
localSmokeFixtureSearchText,
|
||||
} from "./lib/local-smoke-fixture";
|
||||
import {
|
||||
toBackendError,
|
||||
type BackendErrorContext,
|
||||
@@ -45,10 +52,30 @@ import {
|
||||
toBackendErrorNotice,
|
||||
type OperatorNotice,
|
||||
} from "./lib/operator-notices";
|
||||
import { buildQaSnapshot } from "./lib/qa-snapshot";
|
||||
import {
|
||||
buildStructuredInspectorSections,
|
||||
type StructuredInspectorSection,
|
||||
} from "./lib/structured-inspector";
|
||||
import {
|
||||
appendWorkspaceKeyRecords,
|
||||
filterWorkspaceKeys,
|
||||
replaceWorkspaceKeyRecord,
|
||||
resolveSelectedWorkspaceKey,
|
||||
resolveSelectedWorkspaceKeyId,
|
||||
} from "./lib/key-workspace-state";
|
||||
import {
|
||||
buildInspectorEmptyStateMessage,
|
||||
buildKeyBrowserEmptyStateMessage,
|
||||
buildKeyBrowserStatusMessage,
|
||||
resolveKeyBrowserSearchState,
|
||||
} from "./lib/key-browser-search";
|
||||
import {
|
||||
parseDemoTtlToSeconds,
|
||||
persistDemoKeyTtl,
|
||||
updateDemoKeyTtl,
|
||||
updateDemoStringValue,
|
||||
} from "./lib/demo-workspace";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -293,7 +320,7 @@ const initialConnections: ConnectionProfile[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const keyData: KeyRecord[] = [
|
||||
const initialDemoKeys: KeyRecord[] = [
|
||||
{
|
||||
id: "session:42",
|
||||
name: "session:42",
|
||||
@@ -373,11 +400,12 @@ const initialConsoleHistory: ConsoleEntry[] = [
|
||||
|
||||
function App() {
|
||||
const [connections, setConnections] = useState(initialConnections);
|
||||
const [demoKeys, setDemoKeys] = useState(initialDemoKeys);
|
||||
const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id);
|
||||
const [activeDb, setActiveDb] = useState(databases[0]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(keyData[1].id);
|
||||
const [draftValue, setDraftValue] = useState(keyData[1].value);
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(initialDemoKeys[1].id);
|
||||
const [draftValue, setDraftValue] = useState(initialDemoKeys[1].value);
|
||||
const [consoleInput, setConsoleInput] = useState("GET cache:homepage");
|
||||
const [consoleHistory, setConsoleHistory] = useState(initialConsoleHistory);
|
||||
const [bootstrap, setBootstrap] = useState(fallbackBootstrap);
|
||||
@@ -422,18 +450,14 @@ function App() {
|
||||
connections.find((connection) => connection.id === activeConnectionId) ??
|
||||
connections[0];
|
||||
|
||||
const demoQuery = deferredSearch.trim().toLowerCase();
|
||||
const demoKeys = !demoQuery
|
||||
? keyData
|
||||
: keyData.filter((item) => item.name.toLowerCase().includes(demoQuery));
|
||||
const filteredKeys = tauriAvailable ? liveKeys : demoKeys;
|
||||
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);
|
||||
|
||||
const selectedKey =
|
||||
filteredKeys.length === 0
|
||||
? null
|
||||
: filteredKeys.find((item) => item.id === selectedKeyId) ?? filteredKeys[0];
|
||||
const selectedKey = resolveSelectedWorkspaceKey(filteredKeys, selectedKeyId);
|
||||
|
||||
const isEditable = tauriAvailable
|
||||
? selectedValueRecord?.write_capability === "replace_string" &&
|
||||
@@ -459,6 +483,8 @@ function App() {
|
||||
const ttlWriteSupported = tauriAvailable
|
||||
? selectedValueRecord?.ttl_write_supported === true
|
||||
: selectedKey !== null;
|
||||
const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection);
|
||||
const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText;
|
||||
const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null;
|
||||
const canSubmitTtlUpdate =
|
||||
selectedKey !== null &&
|
||||
@@ -474,23 +500,41 @@ function App() {
|
||||
? selectedValueRecord?.metadata.ttl.kind !== "persistent"
|
||||
: selectedKey?.ttl !== "persistent");
|
||||
const latestConsoleEntry = consoleHistory[0] ?? null;
|
||||
const qaSnapshot = [
|
||||
`app=${bootstrap.app_name}`,
|
||||
`version=${appVersion}`,
|
||||
`runtime=${runtimeMode}`,
|
||||
`build=${buildMode}`,
|
||||
`connection=${activeConnection.name}`,
|
||||
`endpoint=${formatEndpoint(activeConnection.target)}`,
|
||||
`database=${activeDb}`,
|
||||
`selectedKey=${selectedKey?.name ?? "none"}`,
|
||||
`selectedType=${selectedKey?.type ?? "none"}`,
|
||||
`selectedTtl=${selectedKey?.ttl ?? "none"}`,
|
||||
`browseVisible=${filteredKeys.length}${tauriAvailable && liveHasMore ? "+" : ""}`,
|
||||
`latestCommand=${latestConsoleEntry?.command ?? "none"}`,
|
||||
`latestCommandSource=${latestConsoleEntry?.source ?? "none"}`,
|
||||
`latestCommandStatus=${latestConsoleEntry?.status ?? "none"}`,
|
||||
`banner=${banner.message}`,
|
||||
].join("\n");
|
||||
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,
|
||||
runtimeMode,
|
||||
buildMode,
|
||||
connectionName: activeConnection.name,
|
||||
endpoint: formatEndpoint(activeConnection.target),
|
||||
databaseLabel: activeDb,
|
||||
smokeFixtureActive: localSmokeFixtureActive,
|
||||
smokeFilter: localSmokeFilterActive ? localSmokeFixtureSearchText : search,
|
||||
selectedKeyName: selectedKey?.name ?? null,
|
||||
selectedKeyType: selectedKey?.type ?? null,
|
||||
selectedKeyTtl: selectedKey?.ttl ?? null,
|
||||
browseVisibleCount: `${filteredKeys.length}${tauriAvailable && liveHasMore ? "+" : ""}`,
|
||||
latestCommand: latestConsoleEntry?.command ?? null,
|
||||
latestCommandSource: latestConsoleEntry?.source ?? null,
|
||||
latestCommandStatus: latestConsoleEntry?.status ?? null,
|
||||
bannerMessage: banner.message,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!tauriAvailable) {
|
||||
@@ -544,7 +588,7 @@ function App() {
|
||||
const request: RedisKeyBrowseRequest = {
|
||||
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
||||
cursor: "0",
|
||||
pattern: toRedisSearchPattern(deferredSearch),
|
||||
pattern: deferredSearchState.requestPattern,
|
||||
page_size: browsePageSize,
|
||||
};
|
||||
|
||||
@@ -569,9 +613,7 @@ function App() {
|
||||
setLiveKeys(nextKeys);
|
||||
setLiveNextCursor(result.next_cursor);
|
||||
setLiveHasMore(result.has_more);
|
||||
setSelectedKeyId((current) =>
|
||||
nextKeys.some((item) => item.id === current) ? current : (nextKeys[0]?.id ?? null),
|
||||
);
|
||||
setSelectedKeyId((current) => resolveSelectedWorkspaceKeyId(nextKeys, current));
|
||||
setIsBrowseLoading(false);
|
||||
});
|
||||
})
|
||||
@@ -600,7 +642,7 @@ function App() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [activeConnectionId, activeDb, deferredSearch, tauriAvailable]);
|
||||
}, [activeConnectionId, activeDb, deferredSearchState.requestPattern, tauriAvailable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tauriAvailable) {
|
||||
@@ -644,7 +686,7 @@ function App() {
|
||||
const nextRecord = toLiveKeyRecordFromValue(record);
|
||||
startTransition(() => {
|
||||
setSelectedValueRecord(record);
|
||||
setLiveKeys((current) => replaceKeyRecord(current, nextRecord));
|
||||
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
||||
setDraftValue(
|
||||
record.data.kind === "string" && record.write_capability === "replace_string"
|
||||
? formatRedisValueContent(record.data.value)
|
||||
@@ -723,6 +765,60 @@ function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUseLocalSmokeFixture = () => {
|
||||
const existingFixture = connections.find(isLocalSmokeFixtureConnection);
|
||||
const fixtureConnection =
|
||||
existingFixture ??
|
||||
{
|
||||
id: localSmokeFixtureProfileId,
|
||||
name: localSmokeFixtureProfileId,
|
||||
environment: "local" as const,
|
||||
detail: "Fixture profile",
|
||||
status: "idle" as const,
|
||||
password: "",
|
||||
target: {
|
||||
host: "127.0.0.1",
|
||||
port: 6380,
|
||||
database: 0,
|
||||
username: null,
|
||||
tls_mode: "disabled" as const,
|
||||
},
|
||||
};
|
||||
|
||||
startTransition(() => {
|
||||
if (!existingFixture) {
|
||||
setConnections((current) => [fixtureConnection, ...current]);
|
||||
}
|
||||
setActiveConnectionId(fixtureConnection.id);
|
||||
setActiveDb(formatDatabaseLabel(fixtureConnection.target.database));
|
||||
setSelectedKeyId(null);
|
||||
setSearch(localSmokeFixtureSearchText);
|
||||
setConsoleInput(localSmokeFixtureCommandPresets[0].command);
|
||||
showAccentBanner(
|
||||
"Loaded the local smoke fixture profile, narrowed the browser to smoke:* keys, and primed the command tray with PING.",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFocusLocalSmokeKeys = () => {
|
||||
startTransition(() => {
|
||||
setSearch(localSmokeFixtureSearchText);
|
||||
setSelectedKeyId(null);
|
||||
showNeutralBanner(
|
||||
`Focused the browser on ${localSmokeFixtureSearchText} for local smoke verification in ${activeDb}.`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyLocalSmokeCommandPreset = (command: string) => {
|
||||
startTransition(() => {
|
||||
setConsoleInput(command);
|
||||
showNeutralBanner(
|
||||
`Loaded ${command} into the command tray so QA can execute the next safe smoke step without retyping.`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateDraftConnection = () => {
|
||||
if (draftErrorList.length > 0) {
|
||||
showNeutralBanner(`Connection draft is incomplete: ${draftErrorList[0]}`);
|
||||
@@ -766,9 +862,12 @@ function App() {
|
||||
}
|
||||
|
||||
if (!tauriAvailable) {
|
||||
showNeutralBanner(
|
||||
`Saved mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is not wired yet.`,
|
||||
);
|
||||
startTransition(() => {
|
||||
setDemoKeys((current) => updateDemoStringValue(current, selectedKey.name, draftValue));
|
||||
showNeutralBanner(
|
||||
`Saved session-local mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is still unavailable in browser demo mode.`,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -791,7 +890,7 @@ function App() {
|
||||
const nextRecord = toLiveKeyRecordFromValue(result);
|
||||
startTransition(() => {
|
||||
setSelectedValueRecord(result);
|
||||
setLiveKeys((current) => replaceKeyRecord(current, nextRecord));
|
||||
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
||||
setDraftValue(
|
||||
result.data.kind === "string"
|
||||
? formatRedisValueContent(result.data.value)
|
||||
@@ -839,9 +938,14 @@ function App() {
|
||||
}
|
||||
|
||||
if (!tauriAvailable) {
|
||||
showNeutralBanner(
|
||||
`Browser dev mode keeps TTL changes in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL update.`,
|
||||
);
|
||||
startTransition(() => {
|
||||
setDemoKeys((current) =>
|
||||
updateDemoKeyTtl(current, selectedKey.name, Number.parseInt(ttlDraft, 10)),
|
||||
);
|
||||
showNeutralBanner(
|
||||
`Updated session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -893,9 +997,13 @@ function App() {
|
||||
}
|
||||
|
||||
if (!tauriAvailable) {
|
||||
showNeutralBanner(
|
||||
`Browser dev mode keeps TTL removal in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL change.`,
|
||||
);
|
||||
startTransition(() => {
|
||||
setDemoKeys((current) => persistDemoKeyTtl(current, selectedKey.name));
|
||||
setTtlDraft("");
|
||||
showNeutralBanner(
|
||||
`Removed session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -967,7 +1075,7 @@ function App() {
|
||||
const nextRecord = toLiveKeyRecordFromValue(result);
|
||||
startTransition(() => {
|
||||
setSelectedValueRecord(result);
|
||||
setLiveKeys((current) => replaceKeyRecord(current, nextRecord));
|
||||
setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord));
|
||||
setDraftValue(
|
||||
result.data.kind === "string" && result.write_capability === "replace_string"
|
||||
? formatRedisValueContent(result.data.value)
|
||||
@@ -1020,11 +1128,15 @@ function App() {
|
||||
metadata,
|
||||
};
|
||||
setSelectedValueRecord(nextRecord);
|
||||
setLiveKeys((current) => replaceKeyRecord(current, toLiveKeyRecordFromValue(nextRecord)));
|
||||
setLiveKeys((current) =>
|
||||
replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromValue(nextRecord)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLiveKeys((current) => replaceKeyRecord(current, toLiveKeyRecordFromMetadata(metadata)));
|
||||
setLiveKeys((current) =>
|
||||
replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromMetadata(metadata)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLoadMoreKeys = async () => {
|
||||
@@ -1041,14 +1153,14 @@ function App() {
|
||||
request: {
|
||||
connection: toRedisConnectionRequest(activeConnection, activeDb),
|
||||
cursor: liveNextCursor,
|
||||
pattern: toRedisSearchPattern(deferredSearch),
|
||||
pattern: deferredSearchState.requestPattern,
|
||||
page_size: browsePageSize,
|
||||
} satisfies RedisKeyBrowseRequest,
|
||||
});
|
||||
|
||||
const nextKeys = result.keys.map(toLiveKeyRecordFromMetadata);
|
||||
startTransition(() => {
|
||||
setLiveKeys((current) => appendKeyRecords(current, nextKeys));
|
||||
setLiveKeys((current) => appendWorkspaceKeyRecords(current, nextKeys));
|
||||
setLiveNextCursor(result.next_cursor);
|
||||
setLiveHasMore(result.has_more);
|
||||
setIsBrowseLoading(false);
|
||||
@@ -1532,6 +1644,67 @@ function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionEyebrow icon={<Activity className="h-4 w-4" />} label="Local smoke assist" />
|
||||
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--ink-0)]">
|
||||
Seeded fixture path for desktop smoke runs
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
Mirrors `docs/qa/local-desktop-smoke.md` and keeps the safe validation path visible inside the shell.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={localSmokeFixtureActive ? "accent" : "neutral"}>
|
||||
{localSmokeFixtureActive ? "active" : "inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="neutral">127.0.0.1:6380</Badge>
|
||||
<Badge variant="neutral">db0</Badge>
|
||||
<Badge variant="neutral">{localSmokeFixtureExpectedKeys.length} seeded keys</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleUseLocalSmokeFixture}>
|
||||
Use fixture
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleFocusLocalSmokeKeys}>
|
||||
Focus smoke:*
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{localSmokeFixtureCommandPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => handleApplyLocalSmokeCommandPreset(preset.command)}
|
||||
className="w-full rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-2 text-left transition hover:border-[var(--line-strong)]"
|
||||
>
|
||||
<p className="font-mono text-sm text-[var(--ink-0)]">{preset.label}</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">{preset.summary}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[18px] border border-[var(--line-soft)] bg-[var(--surface-muted)] px-3 py-3">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--ink-muted)]">
|
||||
Seeded keys
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{localSmokeFixtureExpectedKeys.map((keyName) => (
|
||||
<Badge key={keyName} variant="neutral" className="normal-case tracking-normal">
|
||||
{keyName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionEyebrow icon={<ClipboardList className="h-4 w-4" />} label="QA snapshot" />
|
||||
<div className="mt-3 rounded-[22px] border border-[var(--line-soft)] bg-[var(--surface-base)] p-4">
|
||||
@@ -1571,7 +1744,7 @@ function App() {
|
||||
<Badge variant="neutral">
|
||||
{tauriAvailable
|
||||
? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}`
|
||||
: `${filteredKeys.length} of ${keyData.length} keys in ${activeDb}`}
|
||||
: `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -1583,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">
|
||||
@@ -1621,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}
|
||||
|
||||
@@ -1690,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>
|
||||
)}
|
||||
|
||||
@@ -2147,7 +2336,7 @@ function buildMockCommandOutput(command: string, selectedKey: KeyRecord | null)
|
||||
}
|
||||
|
||||
if (upper === "TTL") {
|
||||
return `(integer) ${selectedKey.ttl === "persistent" ? -1 : selectedKey.ttl.replace(/[^0-9]/g, "")}`;
|
||||
return `(integer) ${parseDemoTtlToSeconds(selectedKey.ttl)}`;
|
||||
}
|
||||
|
||||
if (upper === "TYPE") {
|
||||
@@ -2290,39 +2479,6 @@ function toLiveKeyRecordFromValue(record: RedisValueRecord): KeyRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function replaceKeyRecord(current: KeyRecord[], next: KeyRecord) {
|
||||
let replaced = false;
|
||||
const updated = current.map((item) => {
|
||||
if (item.id !== next.id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
return {
|
||||
...item,
|
||||
...next,
|
||||
};
|
||||
});
|
||||
|
||||
return replaced ? updated : [next, ...current];
|
||||
}
|
||||
|
||||
function appendKeyRecords(current: KeyRecord[], next: KeyRecord[]) {
|
||||
const seen = new Set(current.map((item) => item.id));
|
||||
const appended = [...current];
|
||||
|
||||
for (const item of next) {
|
||||
if (seen.has(item.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(item.id);
|
||||
appended.push(item);
|
||||
}
|
||||
|
||||
return appended;
|
||||
}
|
||||
|
||||
function buildMetadataPreview(metadata: RedisKeyMetadata) {
|
||||
return `${formatRedisKeyTypeLabel(metadata.key_type)} key · TTL ${formatRedisKeyTtl(metadata.ttl)}`;
|
||||
}
|
||||
|
||||
55
apps/desktop/src/lib/demo-workspace.test.ts
Normal file
55
apps/desktop/src/lib/demo-workspace.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDemoStringPreview,
|
||||
formatDemoDurationFromMillis,
|
||||
parseDemoTtlToSeconds,
|
||||
persistDemoKeyTtl,
|
||||
updateDemoKeyTtl,
|
||||
updateDemoStringValue,
|
||||
type DemoWorkspaceKey,
|
||||
} from "./demo-workspace";
|
||||
|
||||
const demoKeys: DemoWorkspaceKey[] = [
|
||||
{
|
||||
id: "cache:homepage",
|
||||
name: "cache:homepage",
|
||||
type: "string",
|
||||
ttl: "48s",
|
||||
preview: "serialized landing payload",
|
||||
editable: true,
|
||||
value: "{\"hero\":\"Redis GUI\"}",
|
||||
summary: "String keys are editable in V1.",
|
||||
},
|
||||
];
|
||||
|
||||
describe("demo workspace helpers", () => {
|
||||
it("updates string values and refreshes the visible preview", () => {
|
||||
expect(
|
||||
updateDemoStringValue(demoKeys, "cache:homepage", "next value"),
|
||||
).toEqual([
|
||||
{
|
||||
...demoKeys[0],
|
||||
value: "next value",
|
||||
preview: "next value",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates TTL state for session-local demo flows", () => {
|
||||
expect(updateDemoKeyTtl(demoKeys, "cache:homepage", 90_000)[0]?.ttl).toBe("1m");
|
||||
expect(persistDemoKeyTtl(demoKeys, "cache:homepage")[0]?.ttl).toBe("persistent");
|
||||
});
|
||||
|
||||
it("formats preview text and durations consistently", () => {
|
||||
expect(buildDemoStringPreview("alpha beta gamma delta epsilon zeta eta theta", 16)).toBe(
|
||||
"alpha beta gamm…",
|
||||
);
|
||||
expect(formatDemoDurationFromMillis(750)).toBe("750ms");
|
||||
expect(formatDemoDurationFromMillis(5_000)).toBe("5s");
|
||||
expect(formatDemoDurationFromMillis(7_200_000)).toBe("2h");
|
||||
expect(parseDemoTtlToSeconds("750ms")).toBe(0);
|
||||
expect(parseDemoTtlToSeconds("5s")).toBe(5);
|
||||
expect(parseDemoTtlToSeconds("2h")).toBe(7200);
|
||||
expect(parseDemoTtlToSeconds("persistent")).toBe(-1);
|
||||
});
|
||||
});
|
||||
114
apps/desktop/src/lib/demo-workspace.ts
Normal file
114
apps/desktop/src/lib/demo-workspace.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
export type DemoWorkspaceKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
ttl: string;
|
||||
preview: string;
|
||||
editable: boolean;
|
||||
value: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export function updateDemoStringValue(
|
||||
keys: DemoWorkspaceKey[],
|
||||
keyName: string,
|
||||
value: string,
|
||||
) {
|
||||
return keys.map((key) =>
|
||||
key.name === keyName
|
||||
? {
|
||||
...key,
|
||||
value,
|
||||
preview: buildDemoStringPreview(value),
|
||||
}
|
||||
: key,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDemoKeyTtl(
|
||||
keys: DemoWorkspaceKey[],
|
||||
keyName: string,
|
||||
ttlMillis: number,
|
||||
) {
|
||||
return keys.map((key) =>
|
||||
key.name === keyName
|
||||
? {
|
||||
...key,
|
||||
ttl: formatDemoDurationFromMillis(ttlMillis),
|
||||
}
|
||||
: key,
|
||||
);
|
||||
}
|
||||
|
||||
export function persistDemoKeyTtl(keys: DemoWorkspaceKey[], keyName: string) {
|
||||
return keys.map((key) =>
|
||||
key.name === keyName
|
||||
? {
|
||||
...key,
|
||||
ttl: "persistent",
|
||||
}
|
||||
: key,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDemoStringPreview(value: string, maxLength = 48) {
|
||||
const collapsed = value.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length <= maxLength) {
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
return `${collapsed.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export function formatDemoDurationFromMillis(value: number) {
|
||||
if (value < 1_000) {
|
||||
return `${value}ms`;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(value / 1_000);
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds}s`;
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
if (totalHours < 24) {
|
||||
return `${totalHours}h`;
|
||||
}
|
||||
|
||||
const totalDays = Math.floor(totalHours / 24);
|
||||
return `${totalDays}d`;
|
||||
}
|
||||
|
||||
export function parseDemoTtlToSeconds(value: string) {
|
||||
if (value === "persistent") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const match = value.trim().match(/^(\d+)(ms|s|m|h|d)$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const amount = Number.parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case "ms":
|
||||
return Math.floor(amount / 1_000);
|
||||
case "s":
|
||||
return amount;
|
||||
case "m":
|
||||
return amount * 60;
|
||||
case "h":
|
||||
return amount * 60 * 60;
|
||||
case "d":
|
||||
return amount * 60 * 60 * 24;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
83
apps/desktop/src/lib/key-browser-search.test.ts
Normal file
83
apps/desktop/src/lib/key-browser-search.test.ts
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
100
apps/desktop/src/lib/key-browser-search.ts
Normal file
100
apps/desktop/src/lib/key-browser-search.ts
Normal 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}"`;
|
||||
}
|
||||
105
apps/desktop/src/lib/key-workspace-state.test.ts
Normal file
105
apps/desktop/src/lib/key-workspace-state.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendWorkspaceKeyRecords,
|
||||
filterWorkspaceKeys,
|
||||
replaceWorkspaceKeyRecord,
|
||||
resolveSelectedWorkspaceKey,
|
||||
resolveSelectedWorkspaceKeyId,
|
||||
} from "./key-workspace-state";
|
||||
|
||||
const workspaceKeys = [
|
||||
{
|
||||
id: "session:42",
|
||||
name: "session:42",
|
||||
ttl: "13m",
|
||||
preview: "hash value",
|
||||
},
|
||||
{
|
||||
id: "cache:homepage",
|
||||
name: "cache:homepage",
|
||||
ttl: "48s",
|
||||
preview: "string value",
|
||||
},
|
||||
];
|
||||
|
||||
describe("key workspace state helpers", () => {
|
||||
it("filters keys by name using trimmed case-insensitive search", () => {
|
||||
expect(filterWorkspaceKeys(workspaceKeys, " CACHE ")).toEqual([
|
||||
workspaceKeys[1],
|
||||
]);
|
||||
expect(filterWorkspaceKeys(workspaceKeys, " ")).toEqual(workspaceKeys);
|
||||
});
|
||||
|
||||
it("resolves the selected key and falls back to the first visible key", () => {
|
||||
expect(resolveSelectedWorkspaceKey(workspaceKeys, "cache:homepage")).toEqual(
|
||||
workspaceKeys[1],
|
||||
);
|
||||
expect(resolveSelectedWorkspaceKey(workspaceKeys, "missing")).toEqual(
|
||||
workspaceKeys[0],
|
||||
);
|
||||
expect(resolveSelectedWorkspaceKey([], "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the selected key id for list refresh flows", () => {
|
||||
expect(resolveSelectedWorkspaceKeyId(workspaceKeys, "cache:homepage")).toBe(
|
||||
"cache:homepage",
|
||||
);
|
||||
expect(resolveSelectedWorkspaceKeyId(workspaceKeys, "missing")).toBe(
|
||||
"session:42",
|
||||
);
|
||||
expect(resolveSelectedWorkspaceKeyId([], "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces an existing key while preserving untouched fields", () => {
|
||||
expect(
|
||||
replaceWorkspaceKeyRecord(workspaceKeys, {
|
||||
id: "cache:homepage",
|
||||
name: "cache:homepage",
|
||||
ttl: "persistent",
|
||||
}),
|
||||
).toEqual([
|
||||
workspaceKeys[0],
|
||||
{
|
||||
...workspaceKeys[1],
|
||||
ttl: "persistent",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("prepends unseen replacements and appends only new paged keys", () => {
|
||||
expect(
|
||||
replaceWorkspaceKeyRecord(workspaceKeys, {
|
||||
id: "smoke:string",
|
||||
name: "smoke:string",
|
||||
ttl: "5m",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "smoke:string",
|
||||
name: "smoke:string",
|
||||
ttl: "5m",
|
||||
},
|
||||
...workspaceKeys,
|
||||
]);
|
||||
|
||||
expect(
|
||||
appendWorkspaceKeyRecords(workspaceKeys, [
|
||||
workspaceKeys[1],
|
||||
{
|
||||
id: "smoke:string",
|
||||
name: "smoke:string",
|
||||
ttl: "5m",
|
||||
preview: "fixture key",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
...workspaceKeys,
|
||||
{
|
||||
id: "smoke:string",
|
||||
name: "smoke:string",
|
||||
ttl: "5m",
|
||||
preview: "fixture key",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
76
apps/desktop/src/lib/key-workspace-state.ts
Normal file
76
apps/desktop/src/lib/key-workspace-state.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export type WorkspaceKeyIdentity = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type WorkspaceSearchableKey = WorkspaceKeyIdentity & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function filterWorkspaceKeys<T extends WorkspaceSearchableKey>(
|
||||
keys: T[],
|
||||
search: string,
|
||||
) {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
return keys.filter((item) => item.name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
export function resolveSelectedWorkspaceKey<T extends WorkspaceKeyIdentity>(
|
||||
keys: T[],
|
||||
selectedKeyId: string | null,
|
||||
) {
|
||||
if (keys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return keys.find((item) => item.id === selectedKeyId) ?? keys[0];
|
||||
}
|
||||
|
||||
export function resolveSelectedWorkspaceKeyId<T extends WorkspaceKeyIdentity>(
|
||||
keys: T[],
|
||||
selectedKeyId: string | null,
|
||||
) {
|
||||
return resolveSelectedWorkspaceKey(keys, selectedKeyId)?.id ?? null;
|
||||
}
|
||||
|
||||
export function replaceWorkspaceKeyRecord<T extends WorkspaceKeyIdentity>(
|
||||
current: T[],
|
||||
next: T,
|
||||
) {
|
||||
let replaced = false;
|
||||
const updated = current.map((item) => {
|
||||
if (item.id !== next.id) {
|
||||
return item;
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
return {
|
||||
...item,
|
||||
...next,
|
||||
};
|
||||
});
|
||||
|
||||
return replaced ? updated : [next, ...current];
|
||||
}
|
||||
|
||||
export function appendWorkspaceKeyRecords<T extends WorkspaceKeyIdentity>(
|
||||
current: T[],
|
||||
next: T[],
|
||||
) {
|
||||
const seen = new Set(current.map((item) => item.id));
|
||||
const appended = [...current];
|
||||
|
||||
for (const item of next) {
|
||||
if (seen.has(item.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(item.id);
|
||||
appended.push(item);
|
||||
}
|
||||
|
||||
return appended;
|
||||
}
|
||||
55
apps/desktop/src/lib/local-smoke-fixture.test.ts
Normal file
55
apps/desktop/src/lib/local-smoke-fixture.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isLocalSmokeFixtureConnection,
|
||||
localSmokeFixtureCommandPresets,
|
||||
localSmokeFixtureExpectedKeys,
|
||||
localSmokeFixtureProfileId,
|
||||
localSmokeFixtureSearchText,
|
||||
} from "./local-smoke-fixture";
|
||||
|
||||
describe("local smoke fixture helpers", () => {
|
||||
it("keeps the expected local smoke verification targets stable", () => {
|
||||
expect(localSmokeFixtureProfileId).toBe("redis-local-fixture");
|
||||
expect(localSmokeFixtureSearchText).toBe("smoke:*");
|
||||
expect(localSmokeFixtureExpectedKeys).toEqual([
|
||||
"smoke:string",
|
||||
"smoke:string:ttl",
|
||||
"smoke:hash",
|
||||
"smoke:list",
|
||||
"smoke:set",
|
||||
"smoke:zset",
|
||||
"smoke:stream",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects the built-in local fixture profile by id or target", () => {
|
||||
expect(
|
||||
isLocalSmokeFixtureConnection({
|
||||
id: "redis-local-fixture",
|
||||
target: { host: "10.0.0.1", port: 6379, database: 2 },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isLocalSmokeFixtureConnection({
|
||||
id: "custom-profile",
|
||||
target: { host: "127.0.0.1", port: 6380, database: 0 },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isLocalSmokeFixtureConnection({
|
||||
id: "redis-local-fixture",
|
||||
target: { host: "127.0.0.1", port: 6380, database: 1 },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps fixture quick commands on a safe read-only path", () => {
|
||||
expect(localSmokeFixtureCommandPresets.map((preset) => preset.command)).toEqual([
|
||||
"PING",
|
||||
"GET smoke:string",
|
||||
"TTL smoke:string:ttl",
|
||||
]);
|
||||
});
|
||||
});
|
||||
53
apps/desktop/src/lib/local-smoke-fixture.ts
Normal file
53
apps/desktop/src/lib/local-smoke-fixture.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export const localSmokeFixtureProfileId = "redis-local-fixture";
|
||||
export const localSmokeFixtureSearchText = "smoke:*";
|
||||
|
||||
export const localSmokeFixtureExpectedKeys = [
|
||||
"smoke:string",
|
||||
"smoke:string:ttl",
|
||||
"smoke:hash",
|
||||
"smoke:list",
|
||||
"smoke:set",
|
||||
"smoke:zset",
|
||||
"smoke:stream",
|
||||
] as const;
|
||||
|
||||
export const localSmokeFixtureCommandPresets = [
|
||||
{
|
||||
id: "ping",
|
||||
label: "PING",
|
||||
command: "PING",
|
||||
summary: "Validate the desktop command bridge against the seeded fixture.",
|
||||
},
|
||||
{
|
||||
id: "get-string",
|
||||
label: "GET smoke:string",
|
||||
command: "GET smoke:string",
|
||||
summary: "Verify the string read path against the seeded smoke key.",
|
||||
},
|
||||
{
|
||||
id: "ttl-string",
|
||||
label: "TTL smoke:string:ttl",
|
||||
command: "TTL smoke:string:ttl",
|
||||
summary: "Verify the TTL path before using the inspector controls.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type LocalSmokeFixtureTarget = {
|
||||
id: string;
|
||||
target: {
|
||||
host: string;
|
||||
port: number;
|
||||
database: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function isLocalSmokeFixtureConnection(
|
||||
connection: LocalSmokeFixtureTarget,
|
||||
) {
|
||||
return (
|
||||
connection.id === localSmokeFixtureProfileId ||
|
||||
(connection.target.host === "127.0.0.1" &&
|
||||
connection.target.port === 6380 &&
|
||||
connection.target.database === 0)
|
||||
);
|
||||
}
|
||||
72
apps/desktop/src/lib/qa-snapshot.test.ts
Normal file
72
apps/desktop/src/lib/qa-snapshot.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildQaSnapshot } from "./qa-snapshot";
|
||||
|
||||
describe("qa snapshot", () => {
|
||||
it("serializes the current smoke context into a stable line-oriented summary", () => {
|
||||
expect(
|
||||
buildQaSnapshot({
|
||||
appName: "Redis GUI Foundation",
|
||||
appVersion: "0.1.0",
|
||||
runtimeMode: "live desktop bridge",
|
||||
buildMode: "production",
|
||||
connectionName: "redis-local-fixture",
|
||||
endpoint: "127.0.0.1:6380",
|
||||
databaseLabel: "db0",
|
||||
smokeFixtureActive: true,
|
||||
smokeFilter: "smoke:*",
|
||||
selectedKeyName: "smoke:string",
|
||||
selectedKeyType: "string",
|
||||
selectedKeyTtl: "persistent",
|
||||
browseVisibleCount: "7",
|
||||
latestCommand: "PING",
|
||||
latestCommandSource: "live",
|
||||
latestCommandStatus: "ok",
|
||||
bannerMessage: "Copied the current QA snapshot to the clipboard for smoke evidence.",
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
"app=Redis GUI Foundation",
|
||||
"version=0.1.0",
|
||||
"runtime=live desktop bridge",
|
||||
"build=production",
|
||||
"connection=redis-local-fixture",
|
||||
"endpoint=127.0.0.1:6380",
|
||||
"database=db0",
|
||||
"smokeFixtureActive=yes",
|
||||
"smokeFilter=smoke:*",
|
||||
"selectedKey=smoke:string",
|
||||
"selectedType=string",
|
||||
"selectedTtl=persistent",
|
||||
"browseVisible=7",
|
||||
"latestCommand=PING",
|
||||
"latestCommandSource=live",
|
||||
"latestCommandStatus=ok",
|
||||
"banner=Copied the current QA snapshot to the clipboard for smoke evidence.",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes empty or missing values so smoke logs stay readable", () => {
|
||||
expect(
|
||||
buildQaSnapshot({
|
||||
appName: "Redis GUI Foundation",
|
||||
appVersion: "0.1.0",
|
||||
runtimeMode: "browser demo fallback",
|
||||
buildMode: "dev",
|
||||
connectionName: "redis-dev-eu-1",
|
||||
endpoint: "127.0.0.1:6379",
|
||||
databaseLabel: "db1",
|
||||
smokeFixtureActive: false,
|
||||
smokeFilter: " ",
|
||||
selectedKeyName: null,
|
||||
selectedKeyType: null,
|
||||
selectedKeyTtl: null,
|
||||
browseVisibleCount: "0",
|
||||
latestCommand: null,
|
||||
latestCommandSource: null,
|
||||
latestCommandStatus: null,
|
||||
bannerMessage: "No key matches the current pattern.",
|
||||
}),
|
||||
).toContain("smokeFilter=none");
|
||||
});
|
||||
});
|
||||
41
apps/desktop/src/lib/qa-snapshot.ts
Normal file
41
apps/desktop/src/lib/qa-snapshot.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type QaSnapshotInput = {
|
||||
appName: string;
|
||||
appVersion: string;
|
||||
runtimeMode: string;
|
||||
buildMode: string;
|
||||
connectionName: string;
|
||||
endpoint: string;
|
||||
databaseLabel: string;
|
||||
smokeFixtureActive: boolean;
|
||||
smokeFilter: string;
|
||||
selectedKeyName: string | null;
|
||||
selectedKeyType: string | null;
|
||||
selectedKeyTtl: string | null;
|
||||
browseVisibleCount: string;
|
||||
latestCommand: string | null;
|
||||
latestCommandSource: string | null;
|
||||
latestCommandStatus: string | null;
|
||||
bannerMessage: string;
|
||||
};
|
||||
|
||||
export function buildQaSnapshot(input: QaSnapshotInput) {
|
||||
return [
|
||||
`app=${input.appName}`,
|
||||
`version=${input.appVersion}`,
|
||||
`runtime=${input.runtimeMode}`,
|
||||
`build=${input.buildMode}`,
|
||||
`connection=${input.connectionName}`,
|
||||
`endpoint=${input.endpoint}`,
|
||||
`database=${input.databaseLabel}`,
|
||||
`smokeFixtureActive=${input.smokeFixtureActive ? "yes" : "no"}`,
|
||||
`smokeFilter=${input.smokeFilter.trim() || "none"}`,
|
||||
`selectedKey=${input.selectedKeyName ?? "none"}`,
|
||||
`selectedType=${input.selectedKeyType ?? "none"}`,
|
||||
`selectedTtl=${input.selectedKeyTtl ?? "none"}`,
|
||||
`browseVisible=${input.browseVisibleCount}`,
|
||||
`latestCommand=${input.latestCommand ?? "none"}`,
|
||||
`latestCommandSource=${input.latestCommandSource ?? "none"}`,
|
||||
`latestCommandStatus=${input.latestCommandStatus ?? "none"}`,
|
||||
`banner=${input.bannerMessage}`,
|
||||
].join("\n");
|
||||
}
|
||||
36
plans/2026-03-31-hac-26-workspace-state-foundation.md
Normal file
36
plans/2026-03-31-hac-26-workspace-state-foundation.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# HAC-26 Workspace State Foundation
|
||||
|
||||
Date: 2026-03-31 UTC
|
||||
Owner: Senior Frontend Engineer
|
||||
Issue: HAC-26
|
||||
Scope: `apps/desktop` frontend foundation only
|
||||
|
||||
## Context
|
||||
|
||||
- `apps/desktop/src/App.tsx` already owns the visible desktop-v1 shell, but it still contains repeated inline logic for key filtering, selected-key fallback, live key replacement, and paged append handling.
|
||||
- These state transitions are part of the browsing and inspector foundation, not one-off UI copy, and upcoming desktop-v1 slices will need them again.
|
||||
- The PM comment on `HAC-26` asked for the highest reusable foundation return, not a new visible feature.
|
||||
|
||||
## Decision
|
||||
|
||||
- Do not expand product scope.
|
||||
- Extract reusable key-workspace state helpers from `App.tsx` into `src/lib`.
|
||||
- Cover the extracted state transitions with focused Vitest cases so future desktop-v1 changes can reuse the same behavior safely.
|
||||
|
||||
## Planned Implementation
|
||||
|
||||
1. Create a shared frontend utility for:
|
||||
- filtering visible keys by search text
|
||||
- resolving selected key fallback when visible results change
|
||||
- replacing a key record after live metadata/value refresh
|
||||
- appending paged browse results without duplicate rows
|
||||
2. Update `App.tsx` to consume the shared utility instead of inline logic.
|
||||
3. Add Vitest coverage for zero-result fallback, selection resolution, replacement merge behavior, and append deduplication.
|
||||
4. Re-run `pnpm --filter @redis-gui/desktop test` and `pnpm run desktop:build`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `App.tsx` no longer owns the reusable key-workspace list and selection transitions inline.
|
||||
- The extracted helper is covered by focused tests.
|
||||
- Existing desktop frontend tests and production build remain green.
|
||||
- No backend contract or visible feature scope changes are introduced.
|
||||
47
plans/2026-03-31-hac-29-browse-search-baseline.md
Normal file
47
plans/2026-03-31-hac-29-browse-search-baseline.md
Normal 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.
|
||||
Reference in New Issue
Block a user