feat(redis): edit JSON values as source with compact saves
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user