refactor(desktop): harden workspace shell foundation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user