feat(redis): edit JSON values as source with compact saves

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-15 14:31:23 +08:00
1 parent deb4cbfdfe
commit ceadf73e4b
11 files changed
+1181 -210

No files matched your search

@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { json } from "@codemirror/lang-json";
import { foldable } from "@codemirror/language";
const redisJsonEditorSource = readFileSync(new URL("../../components/redis/RedisJsonEditor.vue", import.meta.url), "utf8");
const cellDetailEditorSource = readFileSync(new URL("../useCellDetailEditor.ts", import.meta.url), "utf8");
describe("Redis JSON editor folding", () => {
it("opts the Redis JSON editor into the shared folding controls", () => {
expect(redisJsonEditorSource).toMatch(/language:\s*"json"[\s\S]*folding:\s*true/);
expect(cellDetailEditorSource).toContain("...(options.folding ? [foldGutter()] : []),");
expect(cellDetailEditorSource).toContain("...(options.folding ? foldKeymap : []),");
});
it("provides foldable ranges for JSON objects and arrays", () => {
const state = EditorState.create({
doc: `{
"items": [
1,
2
]
}`,
extensions: [json()],
});
const objectLine = state.doc.line(1);
const arrayLine = state.doc.line(2);
const arrayCloseLine = state.doc.line(5);
expect(foldable(state, objectLine.from, objectLine.to)).toEqual({ from: objectLine.to, to: state.doc.line(6).from });
expect(foldable(state, arrayLine.from, arrayLine.to)).toEqual({ from: arrayLine.to, to: arrayCloseLine.from + arrayCloseLine.text.indexOf("]") });
});
});
@@ -1,10 +1,10 @@
import { shallowRef, onBeforeUnmount, getCurrentInstance, type ShallowRef, createApp, watch } from "vue";
import { EditorState, Compartment } from "@codemirror/state";
import { EditorView, keymap, drawSelection, dropCursor, highlightSpecialChars, highlightActiveLine } from "@codemirror/view";
import { EditorView, keymap, drawSelection, dropCursor, highlightSpecialChars, highlightActiveLine, highlightActiveLineGutter, lineNumbers } from "@codemirror/view";
import { json } from "@codemirror/lang-json";
import { search as cmSearch } from "@codemirror/search";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching } from "@codemirror/language";
import { bracketMatching, foldGutter, foldKeymap } from "@codemirror/language";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, cellDetailActiveLineColor, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
@@ -21,8 +21,16 @@ export interface UseCellDetailEditorOptions {
onChange?: (value: string) => void;
onEscape?: () => void;
onBlur?: () => void;
/** Return true after handling a save shortcut so CodeMirror consumes it. */
onSaveShortcut?: (event: KeyboardEvent) => boolean;
language?: "auto" | "json";
readOnly?: boolean;
readOnly?: boolean | (() => boolean);
/** Keep cell detail editors gutter-free unless a caller explicitly opts in. */
lineNumbers?: boolean;
/** A reactive source for opting into CodeMirror line wrapping. */
lineWrapping?: () => boolean;
/** Add CodeMirror fold controls and keyboard bindings for structured source. */
folding?: boolean;
editorTheme: () => EditorTheme;
appAppearance: () => AppThemeAppearance;
appPalette: () => AppThemePalette;
@@ -62,6 +70,8 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
const languageComp = new Compartment();
const themeComp = new Compartment();
const fontThemeComp = new Compartment();
const lineWrappingComp = new Compartment();
const readOnlyComp = new Compartment();
let destroyed = false;
let currentIsJson = false;
@@ -85,6 +95,14 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
wrapperEl.style.setProperty(EDITOR_FONT_FAMILY_CSS_VAR, fontFamily);
}
function isReadOnly(): boolean {
return typeof options.readOnly === "function" ? options.readOnly() : Boolean(options.readOnly);
}
function readOnlyExtensions(readOnly: boolean) {
return [EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly), EditorView.contentAttributes.of(readOnly ? { tabindex: "0" } : {})];
}
function reconfigureFontTheme(size: number, family: string) {
const editor = view.value;
if (!editor) return;
@@ -146,6 +164,24 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
});
});
watch(
() => options.lineWrapping?.() ?? false,
(lineWrapping) => {
const editor = view.value;
if (!editor || destroyed) return;
editor.dispatch({ effects: lineWrappingComp.reconfigure(lineWrapping ? EditorView.lineWrapping : []) });
},
);
watch(
() => isReadOnly(),
(readOnly) => {
const editor = view.value;
if (!editor || destroyed) return;
editor.dispatch({ effects: readOnlyComp.reconfigure(readOnlyExtensions(readOnly)) });
},
);
async function create(parent: HTMLElement, initialValue: string, columnType?: string): Promise<void> {
if (destroyed) return;
@@ -153,6 +189,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
currentIsJson = options.language === "json" || shouldUseJsonMode(columnType, doc);
const theme = await loadEditorTheme(options.editorTheme(), options.appAppearance(), undefined, options.appPalette());
if (destroyed) return;
liveFontSize = clampEditorFontSize(options.fontSize());
const fontTheme = editorFontTheme(EditorView, liveFontSize, options.fontFamily(), { fixedHeight: true, scrollable: true });
const shortcuts = settingsStore.editorSettings.shortcuts;
@@ -167,7 +204,9 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
return { dom };
},
}),
// Minimal setup without line numbers
// Keep the compact detail-editor baseline; gutters and wrapping are opt-in.
...(options.lineNumbers ? [lineNumbers(), highlightActiveLineGutter()] : []),
...(options.folding ? [foldGutter()] : []),
highlightSpecialChars(),
history(),
drawSelection(),
@@ -184,6 +223,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
keymap.of([
...defaultKeymap,
...historyKeymap,
...(options.folding ? foldKeymap : []),
{
key: shortcutToCodeMirrorKey(shortcuts.find),
preventDefault: true,
@@ -196,6 +236,8 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
},
]),
languageComp.of(currentIsJson ? json() : []),
lineWrappingComp.of(options.lineWrapping?.() ? EditorView.lineWrapping : []),
readOnlyComp.of(readOnlyExtensions(isReadOnly())),
themeComp.of(theme),
fontThemeComp.of(fontTheme),
keymap.of([
@@ -218,6 +260,12 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
}
}),
EditorView.domEventHandlers({
keydown(event) {
if (!options.onSaveShortcut?.(event)) return false;
event.preventDefault();
event.stopPropagation();
return true;
},
wheel(event) {
if (!event.metaKey && !event.ctrlKey) return false;
event.preventDefault();
@@ -230,14 +278,13 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
options.onBlur?.();
},
}),
EditorState.readOnly.of(!!options.readOnly),
EditorView.editable.of(!options.readOnly),
// Read-only CodeMirror content is not editable or focusable by default.
// Keep it keyboard-focusable so selection shortcuts stay inside the detail value.
EditorView.contentAttributes.of(options.readOnly ? { tabindex: "0" } : {}),
],
});
// Re-check after the async theme load: a fast v-if unmount can destroy this
// instance while create is still in flight.
if (destroyed) return;
wrapperEl = document.createElement("div");
wrapperEl.style.cssText = "position: relative; width: 100%; height: 100%;";
wrapperEl.addEventListener("gesturestart", onEditorGestureStart);
@@ -254,6 +301,11 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
searchApp = createApp(EditorSearchPanel, { view: view.value });
searchApp.use(i18n);
searchInstance = searchApp.mount(searchMount) as any;
// If unmounted during the last sync steps, drop the late-mounted editor.
if (destroyed) {
destroy();
}
}
function setValue(value: string, columnType?: string) {
@@ -288,14 +340,14 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
}
function destroy() {
if (destroyed) return;
const alreadyDestroyed = destroyed;
destroyed = true;
searchApp?.unmount();
searchApp = null;
searchInstance = null;
view.value?.destroy();
view.value = null;
zoomCommitScheduler.dispose();
if (!alreadyDestroyed) zoomCommitScheduler.dispose();
if (wrapperEl) {
wrapperEl.removeEventListener("gesturestart", onEditorGestureStart);
wrapperEl.removeEventListener("gesturechange", onEditorGestureChange);