feat(redis): add sheet UI components, list set API, and member detail utils

- Add shadcn Sheet UI components for member detail drawer
- Add redisListSet API for editing list items by index
- Add member detail presentation helpers (format, highlight, edit, resize)
- Add tests for redis value presentation
This commit is contained in:
t8y2 2026-05-13 13:54:59 +08:00
parent 90e8bbe757
commit 4dc3a1b3c0
14 changed files with 268 additions and 1 deletions

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui";
import { DialogRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps<DialogRootProps>();
const emits = defineEmits<DialogRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot v-slot="slotProps" data-slot="sheet" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui";
import { DialogClose } from "reka-ui";
const props = defineProps<DialogCloseProps>();
</script>
<template>
<DialogClose data-slot="sheet-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { XIcon } from "lucide-vue-next";
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import SheetOverlay from "./SheetOverlay.vue";
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right",
showCloseButton: true,
});
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "side", "showCloseButton");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogClose as-child>
<SheetOverlay />
</DialogClose>
<DialogContent
data-slot="sheet-content"
:data-side="side"
:class="
cn(
'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<DialogClose v-if="showCloseButton" data-slot="sheet-close" as-child>
<Button variant="ghost" class="absolute top-3 right-3" size="icon-sm">
<XIcon />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DialogDescription } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-muted-foreground text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>
<template>
<div data-slot="sheet-footer" :class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>
<template>
<div data-slot="sheet-header" :class="cn('gap-0.5 p-4 flex flex-col', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DialogTitle } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DialogTitle
data-slot="sheet-title"
:class="cn('text-foreground text-base font-medium cn-font-heading', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui";
import { DialogTrigger } from "reka-ui";
const props = defineProps<DialogTriggerProps>();
</script>
<template>
<DialogTrigger data-slot="sheet-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@ -0,0 +1,8 @@
export { default as Sheet } from "./Sheet.vue";
export { default as SheetClose } from "./SheetClose.vue";
export { default as SheetContent } from "./SheetContent.vue";
export { default as SheetDescription } from "./SheetDescription.vue";
export { default as SheetFooter } from "./SheetFooter.vue";
export { default as SheetHeader } from "./SheetHeader.vue";
export { default as SheetTitle } from "./SheetTitle.vue";
export { default as SheetTrigger } from "./SheetTrigger.vue";

View File

@ -111,6 +111,7 @@ export const redisDeleteKey = forward("redisDeleteKey");
export const redisHashSet = forward("redisHashSet");
export const redisHashDel = forward("redisHashDel");
export const redisListPush = forward("redisListPush");
export const redisListSet = forward("redisListSet");
export const redisListRemove = forward("redisListRemove");
export const redisSetAdd = forward("redisSetAdd");
export const redisSetRemove = forward("redisSetRemove");

View File

@ -597,6 +597,16 @@ export async function redisListPush(connectionId: string, db: number, keyRaw: st
return post("/api/redis/list-push", { connectionId, db, keyRaw, value });
}
export async function redisListSet(
connectionId: string,
db: number,
keyRaw: string,
index: number,
value: string,
): Promise<void> {
return post("/api/redis/list-set", { connectionId, db, keyRaw, index, value });
}
export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise<void> {
return post("/api/redis/list-remove", { connectionId, db, keyRaw, index });
}

View File

@ -5,6 +5,23 @@ export interface RedisMemberDetail {
format: RedisMemberDetailFormat;
}
export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream";
export const REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH = 360;
export const REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH = 900;
export function canEditRedisMemberDetail(kind: RedisMemberDetailKind): boolean {
return kind !== "stream";
}
export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: number): number {
const viewportMax = Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, viewportWidth - 32);
return Math.min(
Math.min(REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH, viewportMax),
Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, width),
);
}
export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
if (typeof value === "string") {
const trimmed = value.trim();
@ -24,3 +41,22 @@ export function formatRedisMemberDetail(value: unknown): RedisMemberDetail {
return { text: String(value), format: "text" };
}
}
export function getRedisMemberSelectionKey(title: string, value: unknown): string {
return `${title}\n${formatRedisMemberDetail(value).text}`;
}
export function highlightRedisJsonDetail(json: string): string {
const escaped = json.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return escaped.replace(
/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let cls = "json-number";
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
else if (match === "true" || match === "false") cls = "json-boolean";
else if (match === "null") cls = "json-null";
return `<span class="${cls}">${match}</span>`;
},
);
}

View File

@ -429,6 +429,16 @@ export async function redisListPush(connectionId: string, db: number, keyRaw: st
return invoke("redis_list_push", { connectionId, db, keyRaw, value });
}
export async function redisListSet(
connectionId: string,
db: number,
keyRaw: string,
index: number,
value: string,
): Promise<void> {
return invoke("redis_list_set", { connectionId, db, keyRaw, index, value });
}
export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise<void> {
return invoke("redis_list_remove", { connectionId, db, keyRaw, index });
}

View File

@ -1,6 +1,12 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { formatRedisMemberDetail } from "../src/lib/redisValuePresentation.ts";
import {
canEditRedisMemberDetail,
clampRedisMemberDetailSheetWidth,
formatRedisMemberDetail,
getRedisMemberSelectionKey,
highlightRedisJsonDetail,
} from "../src/lib/redisValuePresentation.ts";
test("formats JSON object strings for Redis member details", () => {
const detail = formatRedisMemberDetail('{"id":1,"name":"Ada","tags":["dbx","redis"]}');
@ -22,3 +28,35 @@ test("formats non-string Redis member values as JSON", () => {
assert.equal(detail.format, "json");
assert.equal(detail.text, '{\n "field": "name",\n "value": "{\\"nested\\":true}"\n}');
});
test("builds stable Redis member selection keys from title and formatted value", () => {
const key = getRedisMemberSelectionKey("#2", '{"id":240,"kind":"json"}');
assert.equal(key, '#2\n{\n "id": 240,\n "kind": "json"\n}');
});
test("highlights formatted Redis JSON detail safely", () => {
const html = highlightRedisJsonDetail('{"id":240,"name":"<script>","active":true,"meta":null}');
assert.match(html, /<span class="json-key">"id":<\/span>/);
assert.match(html, /<span class="json-number">240<\/span>/);
assert.match(html, /<span class="json-string">"&lt;script&gt;"<\/span>/);
assert.match(html, /<span class="json-boolean">true<\/span>/);
assert.match(html, /<span class="json-null">null<\/span>/);
assert.doesNotMatch(html, /<script>/);
});
test("allows editing Redis collection members except stream fields", () => {
assert.equal(canEditRedisMemberDetail("list"), true);
assert.equal(canEditRedisMemberDetail("hash"), true);
assert.equal(canEditRedisMemberDetail("set"), true);
assert.equal(canEditRedisMemberDetail("zset"), true);
assert.equal(canEditRedisMemberDetail("stream"), false);
});
test("clamps Redis member detail sheet width to viewport and usable bounds", () => {
assert.equal(clampRedisMemberDetailSheetWidth(200, 1200), 360);
assert.equal(clampRedisMemberDetailSheetWidth(640, 1200), 640);
assert.equal(clampRedisMemberDetailSheetWidth(1200, 1400), 900);
assert.equal(clampRedisMemberDetailSheetWidth(900, 500), 468);
});