diff --git a/src/components/ui/sheet/Sheet.vue b/src/components/ui/sheet/Sheet.vue new file mode 100644 index 000000000..ecdbffe0c --- /dev/null +++ b/src/components/ui/sheet/Sheet.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/sheet/SheetClose.vue b/src/components/ui/sheet/SheetClose.vue new file mode 100644 index 000000000..cc1574196 --- /dev/null +++ b/src/components/ui/sheet/SheetClose.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/sheet/SheetContent.vue b/src/components/ui/sheet/SheetContent.vue new file mode 100644 index 000000000..b581b2d91 --- /dev/null +++ b/src/components/ui/sheet/SheetContent.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/ui/sheet/SheetDescription.vue b/src/components/ui/sheet/SheetDescription.vue new file mode 100644 index 000000000..9a8eb2f24 --- /dev/null +++ b/src/components/ui/sheet/SheetDescription.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/sheet/SheetFooter.vue b/src/components/ui/sheet/SheetFooter.vue new file mode 100644 index 000000000..7ac7948ab --- /dev/null +++ b/src/components/ui/sheet/SheetFooter.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/sheet/SheetHeader.vue b/src/components/ui/sheet/SheetHeader.vue new file mode 100644 index 000000000..bad371dd5 --- /dev/null +++ b/src/components/ui/sheet/SheetHeader.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/sheet/SheetTitle.vue b/src/components/ui/sheet/SheetTitle.vue new file mode 100644 index 000000000..ce9aece81 --- /dev/null +++ b/src/components/ui/sheet/SheetTitle.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/sheet/SheetTrigger.vue b/src/components/ui/sheet/SheetTrigger.vue new file mode 100644 index 000000000..a57258ef7 --- /dev/null +++ b/src/components/ui/sheet/SheetTrigger.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/sheet/index.ts b/src/components/ui/sheet/index.ts new file mode 100644 index 000000000..b94a071f2 --- /dev/null +++ b/src/components/ui/sheet/index.ts @@ -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"; diff --git a/src/lib/api.ts b/src/lib/api.ts index 89d2ee42c..dd1b91369 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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"); diff --git a/src/lib/http.ts b/src/lib/http.ts index fa9212076..cc1ca579c 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -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 { + return post("/api/redis/list-set", { connectionId, db, keyRaw, index, value }); +} + export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise { return post("/api/redis/list-remove", { connectionId, db, keyRaw, index }); } diff --git a/src/lib/redisValuePresentation.ts b/src/lib/redisValuePresentation.ts index fdcad551b..d51174c77 100644 --- a/src/lib/redisValuePresentation.ts +++ b/src/lib/redisValuePresentation.ts @@ -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, "&").replace(//g, ">"); + + 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 `${match}`; + }, + ); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 370e75324..a066ed94a 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -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 { + return invoke("redis_list_set", { connectionId, db, keyRaw, index, value }); +} + export async function redisListRemove(connectionId: string, db: number, keyRaw: string, index: number): Promise { return invoke("redis_list_remove", { connectionId, db, keyRaw, index }); } diff --git a/tests/redisValuePresentation.test.ts b/tests/redisValuePresentation.test.ts index 54c4cd928..92fbadb81 100644 --- a/tests/redisValuePresentation.test.ts +++ b/tests/redisValuePresentation.test.ts @@ -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":"