From 1640bdcd07ba044415c6f1496db08ac894adcc82 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Thu, 6 Mar 2025 23:11:13 +0700 Subject: [PATCH 01/12] make json viewer editable --- apps/studio/package.json | 2 + .../assets/styles/components/text-editor.scss | 10 +++ .../common/texteditor/TextEditor.vue | 71 +++++++++++++++--- .../components/sidebar/DetailViewSidebar.vue | 74 ++++++++++++++++--- .../src/components/tableview/TableTable.vue | 40 +++++++--- .../src/lib/editor/plugins/persistJsonFold.ts | 1 + apps/studio/src/lib/editor/utils.ts | 5 +- yarn.lock | 33 +++++---- 8 files changed, 191 insertions(+), 45 deletions(-) diff --git a/apps/studio/package.json b/apps/studio/package.json index 0da2772dd..ac595805f 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -64,6 +64,8 @@ "indent-string": "^4.0.0", "javascript-time-ago": "^2.0.8", "jquery": "^3.5.0", + "json-pointer": "^0.6.2", + "json-source-map": "^0.6.1", "jsonc-parser": "^3.3.1", "knex": "^2.4.1", "knex-firebird-dialect": "1.4.6", diff --git a/apps/studio/src/assets/styles/components/text-editor.scss b/apps/studio/src/assets/styles/components/text-editor.scss index c2e343050..7409906f4 100644 --- a/apps/studio/src/assets/styles/components/text-editor.scss +++ b/apps/studio/src/assets/styles/components/text-editor.scss @@ -37,4 +37,14 @@ color: inherit; } } + + .editable-marker:hover { + background-color: rgba($theme-base, 0.08); + } + + .bks-error-marker { + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-color: rgba($brand-danger, 0.6); + } } diff --git a/apps/studio/src/components/common/texteditor/TextEditor.vue b/apps/studio/src/components/common/texteditor/TextEditor.vue index 446322e21..a64a4ac59 100644 --- a/apps/studio/src/components/common/texteditor/TextEditor.vue +++ b/apps/studio/src/components/common/texteditor/TextEditor.vue @@ -42,7 +42,9 @@ import { } from "@/lib/editor/vim"; import { AppEvent } from "@/common/AppEvent"; import { keymapTypes } from "@/lib/db/types" -import { EditorMarker, LineGutter } from "@/lib/editor/utils"; +import { EditorMarker, LineGutter, EditorRange } from "@/lib/editor/utils"; + +const EDITABLE_MARKER_CLASSNAME = "editable-marker"; interface InitializeOptions { userKeymap?: typeof keymapTypes[number]['value'] @@ -77,6 +79,7 @@ export default { "foldAll", "unfoldAll", "lineGutters", + "editableRanges", ], data() { return { @@ -85,6 +88,7 @@ export default { bookmarkInstances: [], markInstances: [], activeLineGutters: [], + activeEditableRanges: new Map(), wasEditorFocused: false, firstInitialization: true, }; @@ -144,9 +148,6 @@ export default { this.editor.setSize(null, height); this.editor.refresh(); }, - readOnly() { - this.editor?.setOption("readOnly", this.readOnly); - }, lineWrapping() { this.editor?.setOption("lineWrapping", this.lineWrapping); }, @@ -182,6 +183,9 @@ export default { lineGutters() { this.initializeLineGutters(); }, + editableRanges() { + this.initializeEditableRanges(); + }, foldAll() { CodeMirror.commands.foldAll(this.editor) }, @@ -288,17 +292,13 @@ export default { cm.addKeyMap(this.extraKeybindings); } - if (this.readOnly) { - cm.setOption("readOnly", this.readOnly); - } - if (this.lineWrapping) { cm.setOption("lineWrapping", this.lineWrapping); } - cm.on("change", async (cm) => { - this.$emit("input", cm.getValue()); - }); + cm.on("beforeChange", this.handleBeforeChange) + + cm.on("change", this.handleChange) cm.on("keydown", (_cm, e) => { if (this.$store.state.menuActive) { @@ -379,6 +379,7 @@ export default { this.initializeMarkers(); this.initializeBookmarks(); this.initializeLineGutters(); + this.initializeEditableRanges(); this.$emit("update:initialized", true); }) }, @@ -394,7 +395,7 @@ export default { let markInstance: TextMarker; if (marker.type === "error") { markInstance = this.editor.markText(marker.from, marker.to, { - className: "error", + className: "bks-error-marker", }); } else if (marker.type === "highlight") { markInstance = this.editor.markText(marker.from, marker.to, { @@ -456,6 +457,25 @@ export default { this.activeLineGutters.push(lineGutter) }) }, + initializeEditableRanges() { + const ranges: EditorRange[] = this.editableRanges || []; + if (!this.editor) return; + + // Cleanup existing bookmarks + + (this.activeEditableRanges as Map).forEach((_range, marker) => marker.clear()); + this.activeEditableRanges = new Map(); + + for (const range of ranges) { + const marker = this.editor.markText(range.from, range.to, { + className: EDITABLE_MARKER_CLASSNAME, + inclusiveLeft: true, // text inserted on the left will be included + inclusiveRight: true, // text inserted on the right will be included + clearWhenEmpty: false, + }); + this.activeEditableRanges.set(marker, range); + } + }, destroyEditor() { if (this.editor) { this.editor @@ -583,6 +603,33 @@ export default { handleSwitchUserKeymap(value) { this.initialize({ userKeymap: value }); }, + handleBeforeChange(cm: CodeMirror.Editor, changeObj: CodeMirror.EditorChangeCancellable) { + if (this.readOnly && changeObj.origin !== 'setValue') { + const markers = cm.findMarksAt(changeObj.from) + for (const marker of markers) { + // If we are editing inside this marker, don't cancel. + if (marker.className === "editable-marker") { + return + } + } + changeObj.cancel() + } + }, + handleChange(cm: CodeMirror.Editor, changeObj: CodeMirror.EditorChangeLinkedList) { + if (this.readOnly && changeObj.origin !== 'setValue') { + const markers = cm.findMarksAt(changeObj.from) + for (const marker of markers) { + // If we are editing inside this marker, don't cancel. + if (marker.className === EDITABLE_MARKER_CLASSNAME) { + const range = this.activeEditableRanges.get(marker); + const { from, to } = marker.find() + const value = cm.getRange(from, to) + this.$emit("bks-editable-range-change", { range, value }) + } + } + } + this.$emit("input", cm.getValue()); + }, }, async mounted() { await this.initialize({ diff --git a/apps/studio/src/components/sidebar/DetailViewSidebar.vue b/apps/studio/src/components/sidebar/DetailViewSidebar.vue index 9368d8c22..df5467626 100644 --- a/apps/studio/src/components/sidebar/DetailViewSidebar.vue +++ b/apps/studio/src/components/sidebar/DetailViewSidebar.vue @@ -65,6 +65,8 @@ :plugins="textEditorPlugins" :line-gutters="lineGutters" :line-numbers="false" + :editable-ranges="editableRanges" + @bks-editable-range-change="handleEditableRangeChange" />
No Data @@ -98,12 +100,14 @@ import DetailViewSidebarUpsell from '@/components/upsell/DetailViewSidebarUpsell import rawLog from "@bksLogger"; import _ from "lodash"; import globals from '@/common/globals' +import JsonSourceMap from "json-source-map"; +import JsonPointer from "json-pointer"; const log = rawLog.scope("detail-view-sidebar"); export default Vue.extend({ components: { TextEditor, DetailViewSidebarUpsell }, - props: ["value", "hidden", "expandablePaths", "dataId", "title", "reinitialize", "signs"], + props: ["value", "hidden", "expandablePaths", "editablePaths", "dataId", "title", "reinitialize", "signs"], data() { return { reinitializeTextEditor: 0, @@ -111,6 +115,7 @@ export default Vue.extend({ foldAll: 0, unfoldAll: 0, restoredTruncatedPaths: [], + editableRangeErrors: [], }; }, watch: { @@ -142,14 +147,7 @@ export default Vue.extend({ return { name: "javascript", json: true }; }, text() { - if (this.empty) { - return ""; - } - if (this.filter) { - const filtered = deepFilterObjectProps(this.processedValue, this.filter); - return JSON.stringify(filtered, null, 2); - } - return JSON.stringify(this.processedValue, null, 2); + return this.sourceMap.json }, debouncedFilter: { get() { @@ -159,6 +157,18 @@ export default Vue.extend({ this.filter = value; }, 500), }, + sourceMap() { + return JsonSourceMap.stringify(this.filteredValue, null, 2); + }, + filteredValue() { + if (this.empty) { + return {} + } + if (!this.filter) { + return this.processedValue + } + return deepFilterObjectProps(this.processedValue, this.filter); + }, processedValue() { const clonedValue = _.cloneDeep(this.value) eachPaths(clonedValue, (path, value) => { @@ -231,6 +241,16 @@ export default Vue.extend({ log.warn(e); } }) + _.forEach(this.editableRangeErrors, ({ id, error }) => { + const line = findKeyPosition(this.text, [id]); + const { from, to } = findValueInfo(this.lines[line]); + markers.push({ + type: "error", + from: { line, ch: from }, + to: { line, ch: to }, + message: error.message, + }); + }) return markers; }, lines() { @@ -249,6 +269,33 @@ export default Vue.extend({ }) return lineGutters; }, + editableRanges() { + const editablePaths = this.editablePaths + + if (_.isEmpty(editablePaths)) { + return [] + } + + const ranges = [] + + editablePaths.forEach((path: string) => { + const pointer = JsonPointer.compile(path.split(".")) + const position = this.sourceMap.pointers[pointer] + + if (!position) { + log.warn(`Unable to find editable path \`${path}\` in value object.`) + return + } + + ranges.push({ + id: path, + from: { line: position.value.line, ch: position.value.column }, + to: { line: position.valueEnd.line, ch: position.valueEnd.column }, + }) + }) + + return ranges + }, menuOptions() { return [ { @@ -291,6 +338,15 @@ export default Vue.extend({ close() { this.$emit("close") }, + handleEditableRangeChange: _.debounce(function ({ range, value }) { + try { + const parsed = JSON.parse(value) + this.editableRangeErrors = [] + this.$emit("bks-json-value-change", {key: range.id, value: parsed}); + } catch (error) { + this.editableRangeErrors.push({ id: range.id, error }) + } + }, 250), }, }); diff --git a/apps/studio/src/components/tableview/TableTable.vue b/apps/studio/src/components/tableview/TableTable.vue index 7f9ac7982..1062032e7 100644 --- a/apps/studio/src/components/tableview/TableTable.vue +++ b/apps/studio/src/components/tableview/TableTable.vue @@ -39,10 +39,12 @@ :data-id="selectedRowIndex" :hidden="!openDetailView" :expandable-paths="expandablePaths" + :editable-paths="editablePaths" :reinitialize="reinitializeDetailView" :signs="selectedRowDataSigns" @expandPath="expandForeignKey" @close="toggleOpenDetailView(false)" + @bks-json-value-change="handleJsonValueChange" />
- Import from file + Import from file col.columnName === columnName); @@ -1245,7 +1260,7 @@ export default Vue.extend({ } // reflect changes in the detail view - if (this.indexRowOf(cell.getRow()) === this.selectedRowIndex) { + if (this.positionRowOf(cell.getRow()) === this.selectedRowIndex) { cell.getRow().invalidateForeignCache(cell.getField()) this.updateDetailView() } @@ -1293,7 +1308,7 @@ export default Vue.extend({ oldValue: cell.getOldValue(), cell: cell, value: cell.getValue(0), - rowIndex: this.indexRowOf(cell.getRow()) + rowIndex: this.positionRowOf(cell.getRow()) } // remove existing pending updates with identical pKey-column combo let pendingUpdates = _.reject(this.pendingChanges.updates, { 'key': payload.key }) @@ -1718,7 +1733,7 @@ export default Vue.extend({ this.updateSplit(open) this.rootToggleOpenDetailView(open) }, - indexRowOf(row: RowComponent) { + positionRowOf(row: RowComponent) { return (this.limit * (this.page - 1)) + (row.getPosition() || 0) }, updateDetailView(options: { range?: RangeComponent } = {}) { @@ -1728,16 +1743,17 @@ export default Vue.extend({ const row = range.getRows()[0] if (!row) { this.selectedRow = null - this.selectedRowIndex = null + this.selectedRowPosition = null this.selectedRowData = {} return } - const position = this.indexRowOf(row) + const position = this.positionRowOf(row) const data = row.getData("withForeignData") const cachedExpandablePaths = row.getExpandablePaths() this.detailViewTitle = `Row ${position}` this.selectedRow = row - this.selectedRowIndex = position + this.selectedRowPosition = position + this.selectedRowIndex = this.primaryKeys?.map((key: string) => data[key]).join(','); this.selectedRowData = this.$bks.cleanData(data, this.tableColumns) this.expandablePaths = this.rawTableKeys .filter((key) => !row.hasForeignData([key.fromColumn])) @@ -1872,6 +1888,10 @@ export default Vue.extend({ this.split = null } }, + handleJsonValueChange({key, value}) { + console.log('handleJsonValueChange', key, value) + this.tabulator.getRow(this.selectedRowIndex).getCell(key).setValue(value) + }, debouncedSaveTab: _.debounce(function(tab) { this.$store.dispatch('tabs/save', tab) }, 300), diff --git a/apps/studio/src/lib/editor/plugins/persistJsonFold.ts b/apps/studio/src/lib/editor/plugins/persistJsonFold.ts index 073269c3f..c2c8e0d84 100644 --- a/apps/studio/src/lib/editor/plugins/persistJsonFold.ts +++ b/apps/studio/src/lib/editor/plugins/persistJsonFold.ts @@ -5,6 +5,7 @@ import _ from "lodash"; import { findKeyPosition } from "@/lib/data/detail_view"; +// FIXME (azmi): maybe use json-source-map instead? import { getLocation, JSONPath } from "jsonc-parser"; import CodeMirror from "codemirror"; diff --git a/apps/studio/src/lib/editor/utils.ts b/apps/studio/src/lib/editor/utils.ts index efcdd32e1..62f78e040 100644 --- a/apps/studio/src/lib/editor/utils.ts +++ b/apps/studio/src/lib/editor/utils.ts @@ -1,9 +1,12 @@ import * as CodeMirrorPlugins from "@/lib/editor/CodeMirrorPlugins"; import { TableOrView } from "../db/models"; -export interface EditorMarker { +export interface EditorRange { from: { line: number; ch: number }; to: { line: number; ch: number }; +} + +export interface EditorMarker extends EditorRange { message?: string; element?: HTMLElement; onClick?: (event: MouseEvent) => void; diff --git a/yarn.lock b/yarn.lock index 2c5a89be9..d19333845 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4581,11 +4581,6 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -buildcheck@~0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" - integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== - builder-util-runtime@9.2.10, builder-util-runtime@^9.1.1: version "9.2.10" resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz#a0f7d9e214158402e78b74a745c8d9f870c604bc" @@ -5135,13 +5130,8 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cpu-features@~0.0.10: - version "0.0.10" - resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" - integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== - dependencies: - buildcheck "~0.0.6" - nan "^2.19.0" +"cpu-features@file:./.yarn/packages/empty-package", cpu-features@~0.0.10: + version "1.0.0" crc-32@^1.2.0: version "1.2.2" @@ -6229,6 +6219,11 @@ follow-redirects@^1.15.6: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== +foreach@^2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.6.tgz#87bcc8a1a0e74000ff2bf9802110708cfb02eb6e" + integrity sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg== + foreground-child@^3.1.0: version "3.3.0" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77" @@ -7682,11 +7677,23 @@ json-parse-even-better-errors@^2.3.0: resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== +json-pointer@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/json-pointer/-/json-pointer-0.6.2.tgz#f97bd7550be5e9ea901f8c9264c9d436a22a93cd" + integrity sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw== + dependencies: + foreach "^2.0.4" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/json-source-map/-/json-source-map-0.6.1.tgz#e0b1f6f4ce13a9ad57e2ae165a24d06e62c79a0f" + integrity sha512-1QoztHPsMQqhDq0hlXY5ZqcEdUzxQEIxgFkKl4WUp2pgShObl+9ovi4kRh2TfvAfxAoHOJ9vIMEqk3k4iex7tg== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -8513,7 +8520,7 @@ named-placeholders@^1.1.3: dependencies: lru-cache "^7.14.1" -nan@^2.19.0, nan@^2.20.0: +nan@^2.20.0: version "2.22.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.0.tgz#31bc433fc33213c97bad36404bb68063de604de3" integrity sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw== From 1b8a8fb35aba9c97cb6b44c7dd5a30479204b952 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Fri, 7 Mar 2025 00:51:48 +0700 Subject: [PATCH 02/12] highlight editable marker when cursor is on it --- .../assets/styles/components/text-editor.scss | 2 +- .../common/texteditor/TextEditor.vue | 55 +++++++++++++++---- apps/studio/src/lib/editor/utils.ts | 1 + 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/studio/src/assets/styles/components/text-editor.scss b/apps/studio/src/assets/styles/components/text-editor.scss index 7409906f4..626a58925 100644 --- a/apps/studio/src/assets/styles/components/text-editor.scss +++ b/apps/studio/src/assets/styles/components/text-editor.scss @@ -38,7 +38,7 @@ } } - .editable-marker:hover { + .bks-editable-marker:hover, .bks-editable-marker-active { background-color: rgba($theme-base, 0.08); } diff --git a/apps/studio/src/components/common/texteditor/TextEditor.vue b/apps/studio/src/components/common/texteditor/TextEditor.vue index a64a4ac59..34dec78c9 100644 --- a/apps/studio/src/components/common/texteditor/TextEditor.vue +++ b/apps/studio/src/components/common/texteditor/TextEditor.vue @@ -44,7 +44,9 @@ import { AppEvent } from "@/common/AppEvent"; import { keymapTypes } from "@/lib/db/types" import { EditorMarker, LineGutter, EditorRange } from "@/lib/editor/utils"; -const EDITABLE_MARKER_CLASSNAME = "editable-marker"; +const EDITABLE_MARKER_CLASSNAME = "bks-editable-marker"; +const EDITABLE_MARKER_ACTIVE_CLASSNAME = "bks-editable-marker-active"; +const EDITABLE_MARKER_ATTR_ID = "data-bks-editable-id"; interface InitializeOptions { userKeymap?: typeof keymapTypes[number]['value'] @@ -89,6 +91,7 @@ export default { markInstances: [], activeLineGutters: [], activeEditableRanges: new Map(), + activeEditableMarkerId: null, wasEditorFocused: false, firstInitialization: true, }; @@ -333,6 +336,7 @@ export default { "update:cursorIndexAnchor", cm.getDoc().indexFromPos(cm.getCursor('anchor')) ); + this.handleCursorActivity(cm); }); const cmEl = this.$refs.editor.parentNode.querySelector(".CodeMirror"); @@ -463,7 +467,7 @@ export default { // Cleanup existing bookmarks - (this.activeEditableRanges as Map).forEach((_range, marker) => marker.clear()); + this.activeEditableRanges.forEach((_id: string, { marker }) => marker.clear()); this.activeEditableRanges = new Map(); for (const range of ranges) { @@ -472,8 +476,9 @@ export default { inclusiveLeft: true, // text inserted on the left will be included inclusiveRight: true, // text inserted on the right will be included clearWhenEmpty: false, + attributes: { [EDITABLE_MARKER_ATTR_ID]: range.id }, }); - this.activeEditableRanges.set(marker, range); + this.activeEditableRanges.set(range.id, { marker, range }); } }, destroyEditor() { @@ -608,7 +613,7 @@ export default { const markers = cm.findMarksAt(changeObj.from) for (const marker of markers) { // If we are editing inside this marker, don't cancel. - if (marker.className === "editable-marker") { + if (marker.className === EDITABLE_MARKER_CLASSNAME) { return } } @@ -619,17 +624,45 @@ export default { if (this.readOnly && changeObj.origin !== 'setValue') { const markers = cm.findMarksAt(changeObj.from) for (const marker of markers) { - // If we are editing inside this marker, don't cancel. - if (marker.className === EDITABLE_MARKER_CLASSNAME) { - const range = this.activeEditableRanges.get(marker); - const { from, to } = marker.find() - const value = cm.getRange(from, to) - this.$emit("bks-editable-range-change", { range, value }) - } + // @ts-expect-error not fully typed + const id = marker.attributes?.[EDITABLE_MARKER_ATTR_ID] + if (!id) continue + + const { range } = this.activeEditableRanges.get(id); + const { from, to } = marker.find() + const value = cm.getRange(from, to) + this.$emit("bks-editable-range-change", { range, value }) } } this.$emit("input", cm.getValue()); }, + handleCursorActivity(cm: CodeMirror.Editor) { + const cursor = cm.getCursor(); + const markers = cm.findMarksAt(cursor); + let activeMarkerId: string; + + markers.forEach((marker) => { + // @ts-expect-error not fully typed + const id = marker.attributes?.[EDITABLE_MARKER_ATTR_ID] + if (!id) return + + activeMarkerId = id + + const markerEls = cm.getWrapperElement().querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${id}"]`) + markerEls.forEach((el) => { + el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, true) + }) + }) + + if (this.activeEditableMarkerId && this.activeEditableMarkerId !== activeMarkerId) { + const markerEls = cm.getWrapperElement().querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${this.activeEditableMarkerId}"]`) + markerEls.forEach((el) => { + el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, false) + }) + } + + this.activeEditableMarkerId = activeMarkerId + }, }, async mounted() { await this.initialize({ diff --git a/apps/studio/src/lib/editor/utils.ts b/apps/studio/src/lib/editor/utils.ts index 62f78e040..4c3dcd8af 100644 --- a/apps/studio/src/lib/editor/utils.ts +++ b/apps/studio/src/lib/editor/utils.ts @@ -2,6 +2,7 @@ import * as CodeMirrorPlugins from "@/lib/editor/CodeMirrorPlugins"; import { TableOrView } from "../db/models"; export interface EditorRange { + id?: string from: { line: number; ch: number }; to: { line: number; ch: number }; } From a495161568e79fbd6c2d1c6b28f9e09d17cc2db2 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Fri, 7 Mar 2025 05:03:50 +0700 Subject: [PATCH 03/12] wip --- .../common/texteditor/TextEditor.vue | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/apps/studio/src/components/common/texteditor/TextEditor.vue b/apps/studio/src/components/common/texteditor/TextEditor.vue index 34dec78c9..441503b6c 100644 --- a/apps/studio/src/components/common/texteditor/TextEditor.vue +++ b/apps/studio/src/components/common/texteditor/TextEditor.vue @@ -43,10 +43,7 @@ import { import { AppEvent } from "@/common/AppEvent"; import { keymapTypes } from "@/lib/db/types" import { EditorMarker, LineGutter, EditorRange } from "@/lib/editor/utils"; - -const EDITABLE_MARKER_CLASSNAME = "bks-editable-marker"; -const EDITABLE_MARKER_ACTIVE_CLASSNAME = "bks-editable-marker-active"; -const EDITABLE_MARKER_ATTR_ID = "data-bks-editable-id"; +import { TextEditorPlugin } from "@/lib/editor/plugins/TextEditorPlugin"; interface InitializeOptions { userKeymap?: typeof keymapTypes[number]['value'] @@ -94,6 +91,7 @@ export default { activeEditableMarkerId: null, wasEditorFocused: false, firstInitialization: true, + installedPlugins: [], }; }, computed: { @@ -367,8 +365,15 @@ export default { } if (this.plugins) { - this.plugins.forEach((plugin: (cm: CodeMirror.Editor) => void) => { - plugin(cm); + this.plugins.forEach((plugin: unknown) => { + if (typeof plugin === "function") { + plugin(cm); + } else { + const cls = plugin as new () => TextEditorPlugin; + const ins = new cls() + ins.initialize(cm); + this.installedPlugins.push(ins) + } }); } @@ -461,32 +466,15 @@ export default { this.activeLineGutters.push(lineGutter) }) }, - initializeEditableRanges() { - const ranges: EditorRange[] = this.editableRanges || []; - if (!this.editor) return; - - // Cleanup existing bookmarks - - this.activeEditableRanges.forEach((_id: string, { marker }) => marker.clear()); - this.activeEditableRanges = new Map(); - - for (const range of ranges) { - const marker = this.editor.markText(range.from, range.to, { - className: EDITABLE_MARKER_CLASSNAME, - inclusiveLeft: true, // text inserted on the left will be included - inclusiveRight: true, // text inserted on the right will be included - clearWhenEmpty: false, - attributes: { [EDITABLE_MARKER_ATTR_ID]: range.id }, - }); - this.activeEditableRanges.set(range.id, { marker, range }); - } - }, destroyEditor() { if (this.editor) { this.editor .getWrapperElement() .parentNode.removeChild(this.editor.getWrapperElement()); } + this.installedPlugins.forEach((plugin: TextEditorPlugin) => { + plugin.destroy() + }) }, showContextMenu(event) { const hasSelectedText = this.editor.getSelection(); From c4a4f6e49765b13be8e4eaa45549a8002fb8330c Mon Sep 17 00:00:00 2001 From: azmy60 Date: Fri, 7 Mar 2025 18:30:53 +0700 Subject: [PATCH 04/12] move implementation to plugin --- .../common/texteditor/TextEditor.vue | 125 +++++-------- .../components/sidebar/DetailViewSidebar.vue | 10 +- .../editor/plugins/PartialReadOnlyPlugin.ts | 176 ++++++++++++++++++ .../lib/editor/plugins/TextEditorPlugin.ts | 5 + apps/studio/src/lib/editor/utils.ts | 14 +- 5 files changed, 243 insertions(+), 87 deletions(-) create mode 100644 apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts create mode 100644 apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts diff --git a/apps/studio/src/components/common/texteditor/TextEditor.vue b/apps/studio/src/components/common/texteditor/TextEditor.vue index 441503b6c..36f17a57b 100644 --- a/apps/studio/src/components/common/texteditor/TextEditor.vue +++ b/apps/studio/src/components/common/texteditor/TextEditor.vue @@ -42,13 +42,16 @@ import { } from "@/lib/editor/vim"; import { AppEvent } from "@/common/AppEvent"; import { keymapTypes } from "@/lib/db/types" -import { EditorMarker, LineGutter, EditorRange } from "@/lib/editor/utils"; +import { EditorMarker, LineGutter } from "@/lib/editor/utils"; import { TextEditorPlugin } from "@/lib/editor/plugins/TextEditorPlugin"; +import rawLog from '@bksLogger' interface InitializeOptions { userKeymap?: typeof keymapTypes[number]['value'] } +const log = rawLog.scope('TextEditor') + export default { props: [ "value", @@ -78,7 +81,6 @@ export default { "foldAll", "unfoldAll", "lineGutters", - "editableRanges", ], data() { return { @@ -87,11 +89,9 @@ export default { bookmarkInstances: [], markInstances: [], activeLineGutters: [], - activeEditableRanges: new Map(), - activeEditableMarkerId: null, wasEditorFocused: false, firstInitialization: true, - installedPlugins: [], + initializedPlugins: [], }; }, computed: { @@ -149,6 +149,9 @@ export default { this.editor.setSize(null, height); this.editor.refresh(); }, + readOnly() { + this.editor?.setOption("readOnly", this.readOnly); + }, lineWrapping() { this.editor?.setOption("lineWrapping", this.lineWrapping); }, @@ -184,15 +187,18 @@ export default { lineGutters() { this.initializeLineGutters(); }, - editableRanges() { - this.initializeEditableRanges(); - }, foldAll() { CodeMirror.commands.foldAll(this.editor) }, unfoldAll() { CodeMirror.commands.unfoldAll(this.editor) }, + plugins() { + this.destroyPlugins(); + if (this.editor) { + this.initializePlugins(this.editor); + } + }, }, methods: { focusEditor() { @@ -297,9 +303,9 @@ export default { cm.setOption("lineWrapping", this.lineWrapping); } - cm.on("beforeChange", this.handleBeforeChange) - - cm.on("change", this.handleChange) + cm.on("change", async (cm) => { + this.$emit("input", cm.getValue()); + }); cm.on("keydown", (_cm, e) => { if (this.$store.state.menuActive) { @@ -334,7 +340,6 @@ export default { "update:cursorIndexAnchor", cm.getDoc().indexFromPos(cm.getCursor('anchor')) ); - this.handleCursorActivity(cm); }); const cmEl = this.$refs.editor.parentNode.querySelector(".CodeMirror"); @@ -364,18 +369,7 @@ export default { } } - if (this.plugins) { - this.plugins.forEach((plugin: unknown) => { - if (typeof plugin === "function") { - plugin(cm); - } else { - const cls = plugin as new () => TextEditorPlugin; - const ins = new cls() - ins.initialize(cm); - this.installedPlugins.push(ins) - } - }); - } + this.initializePlugins(cm) if (this.firstInitialization && this.focus) { cm.focus(); @@ -388,7 +382,6 @@ export default { this.initializeMarkers(); this.initializeBookmarks(); this.initializeLineGutters(); - this.initializeEditableRanges(); this.$emit("update:initialized", true); }) }, @@ -466,15 +459,38 @@ export default { this.activeLineGutters.push(lineGutter) }) }, + initializePlugins(editor: CodeMirror.Editor) { + this.plugins?.forEach((plugin: ((editor: CodeMirror.Editor) => Function) | TextEditorPlugin) => { + try { + if (typeof plugin === "function") { + const destroy = plugin(editor); + this.initializedPlugins.push({ destroy }); + } else { + plugin.initialize(editor); + this.initializedPlugins.push(plugin); + } + } catch (e) { + log.error("Error initializing plugin", e) + } + }); + }, destroyEditor() { + this.destroyPlugins(); if (this.editor) { this.editor .getWrapperElement() .parentNode.removeChild(this.editor.getWrapperElement()); } - this.installedPlugins.forEach((plugin: TextEditorPlugin) => { - plugin.destroy() + }, + destroyPlugins() { + this.initializedPlugins.forEach((plugin: TextEditorPlugin) => { + try { + plugin.destroy() + } catch (e) { + log.error("Error destroying plugin", e) + } }) + this.initializedPlugins = [] }, showContextMenu(event) { const hasSelectedText = this.editor.getSelection(); @@ -596,61 +612,6 @@ export default { handleSwitchUserKeymap(value) { this.initialize({ userKeymap: value }); }, - handleBeforeChange(cm: CodeMirror.Editor, changeObj: CodeMirror.EditorChangeCancellable) { - if (this.readOnly && changeObj.origin !== 'setValue') { - const markers = cm.findMarksAt(changeObj.from) - for (const marker of markers) { - // If we are editing inside this marker, don't cancel. - if (marker.className === EDITABLE_MARKER_CLASSNAME) { - return - } - } - changeObj.cancel() - } - }, - handleChange(cm: CodeMirror.Editor, changeObj: CodeMirror.EditorChangeLinkedList) { - if (this.readOnly && changeObj.origin !== 'setValue') { - const markers = cm.findMarksAt(changeObj.from) - for (const marker of markers) { - // @ts-expect-error not fully typed - const id = marker.attributes?.[EDITABLE_MARKER_ATTR_ID] - if (!id) continue - - const { range } = this.activeEditableRanges.get(id); - const { from, to } = marker.find() - const value = cm.getRange(from, to) - this.$emit("bks-editable-range-change", { range, value }) - } - } - this.$emit("input", cm.getValue()); - }, - handleCursorActivity(cm: CodeMirror.Editor) { - const cursor = cm.getCursor(); - const markers = cm.findMarksAt(cursor); - let activeMarkerId: string; - - markers.forEach((marker) => { - // @ts-expect-error not fully typed - const id = marker.attributes?.[EDITABLE_MARKER_ATTR_ID] - if (!id) return - - activeMarkerId = id - - const markerEls = cm.getWrapperElement().querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${id}"]`) - markerEls.forEach((el) => { - el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, true) - }) - }) - - if (this.activeEditableMarkerId && this.activeEditableMarkerId !== activeMarkerId) { - const markerEls = cm.getWrapperElement().querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${this.activeEditableMarkerId}"]`) - markerEls.forEach((el) => { - el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, false) - }) - } - - this.activeEditableMarkerId = activeMarkerId - }, }, async mounted() { await this.initialize({ diff --git a/apps/studio/src/components/sidebar/DetailViewSidebar.vue b/apps/studio/src/components/sidebar/DetailViewSidebar.vue index df5467626..aa4551eb0 100644 --- a/apps/studio/src/components/sidebar/DetailViewSidebar.vue +++ b/apps/studio/src/components/sidebar/DetailViewSidebar.vue @@ -65,8 +65,6 @@ :plugins="textEditorPlugins" :line-gutters="lineGutters" :line-numbers="false" - :editable-ranges="editableRanges" - @bks-editable-range-change="handleEditableRangeChange" />
No Data @@ -96,6 +94,7 @@ import { import { mapGetters } from "vuex"; import { EditorMarker, LineGutter } from "@/lib/editor/utils"; import { persistJsonFold } from "@/lib/editor/plugins/persistJsonFold"; +import PartialReadOnlyPlugin from "@/lib/editor/plugins/PartialReadOnlyPlugin"; import DetailViewSidebarUpsell from '@/components/upsell/DetailViewSidebarUpsell.vue' import rawLog from "@bksLogger"; import _ from "lodash"; @@ -327,7 +326,10 @@ export default Vue.extend({ ] }, textEditorPlugins() { - return [persistJsonFold] + return [ + persistJsonFold, + new PartialReadOnlyPlugin(this.editableRanges, this.handleEditableRangeChange), + ] }, ...mapGetters(["expandFKDetailsByDefault"]), }, @@ -338,7 +340,7 @@ export default Vue.extend({ close() { this.$emit("close") }, - handleEditableRangeChange: _.debounce(function ({ range, value }) { + handleEditableRangeChange: _.debounce(function (range, value) { try { const parsed = JSON.parse(value) this.editableRangeErrors = [] diff --git a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts new file mode 100644 index 000000000..549f792fc --- /dev/null +++ b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts @@ -0,0 +1,176 @@ +/** + * This plugin sets the whole editor to be read-only except for some defined + * ranges. **/ + +import _ from "lodash"; +import { EditorRange, isPositionWithin } from "@/lib/editor/utils"; +import { TextEditorPlugin } from "./TextEditorPlugin"; +import CodeMirror from "codemirror"; +// FIXME (azmi): maybe use json-ource-map instead? + +const EDITABLE_MARKER_CLASSNAME = "bks-editable-marker"; +const EDITABLE_MARKER_ACTIVE_CLASSNAME = "bks-editable-marker-active"; +const EDITABLE_MARKER_ATTR_ID = "data-bks-editable-id"; + +export default class PartialReadOnlyPlugin extends TextEditorPlugin { + name = "partialReadOnly"; + rangeMap = new Map< + string, + { + range: EditorRange; + marker: CodeMirror.TextMarker; + } + >(); + activeMarkerId?: string; + editor: CodeMirror.Editor; + + constructor( + private ranges: EditorRange[], + private onEditableRangeChange?: (range: EditorRange, value: string) => void + ) { + super(); + + this.handleBeforeChange = this.handleBeforeChange.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleCursorActivity = this.handleCursorActivity.bind(this); + } + + initialize(editor: CodeMirror.Editor) { + if (editor.getOption("readOnly")) { + throw new Error( + "PartialReadOnlyPlugin requires `readOnly` to be disabled." + ); + } + + this.editor = editor; + + this.ranges.forEach((range) => { + const marker = editor.markText(range.from, range.to, { + className: EDITABLE_MARKER_CLASSNAME, + inclusiveLeft: true, // text inserted on the left will be included + inclusiveRight: true, // text inserted on the right will be included + clearWhenEmpty: false, + // @ts-expect-error not fully typed + attributes: { [EDITABLE_MARKER_ATTR_ID]: range.id }, + }); + this.rangeMap.set(range.id, { + range, + marker, + }); + }); + + this.editor.on("beforeChange", this.handleBeforeChange); + this.editor.on("change", this.handleChange); + this.editor.on("cursorActivity", this.handleCursorActivity); + } + + destroy(): void { + this.rangeMap.forEach((range) => range.marker.clear()); + if (this.editor) { + this.editor.off("beforeChange", this.handleBeforeChange); + this.editor.off("change", this.handleChange); + this.editor.off("cursorActivity", this.handleCursorActivity); + } + } + + handleBeforeChange( + cm: CodeMirror.Editor, + changeObj: CodeMirror.EditorChangeCancellable + ) { + if (changeObj.origin !== "setValue") { + let markers = cm.findMarks(changeObj.from, changeObj.to); + if (markers.length === 0) { + markers = cm.findMarksAt(changeObj.from); + } + + for (const marker of markers) { + if ( + this.getMarkerId(marker) && + isPositionWithin(changeObj, marker.find()) + ) { + return; + } + } + changeObj.cancel(); + } + } + + handleChange( + cm: CodeMirror.Editor, + changeObj: CodeMirror.EditorChangeLinkedList + ){ + // Skip the whole process if there is no callback + if (!this.onEditableRangeChange) { + return; + } + + if (changeObj.origin !== "setValue") { + const markers = cm.findMarksAt(changeObj.from); + for (const marker of markers) { + if (!this.getMarkerId(marker)) { + continue; + } + const { range } = this.rangeMap.get(this.getMarkerId(marker)); + const value = this.getMarkerValue(marker); + this.onEditableRangeChange(range, value); + } + } + }; + + handleCursorActivity(cm: CodeMirror.Editor) { + const cursor = cm.getCursor(); + const markers = cm.findMarksAt(cursor); + const activeMarker = this.findActiveMarker(markers); + + let activeMarkerId: string; + + if (activeMarker) { + activeMarkerId = this.getMarkerId(activeMarker); + setTimeout(() => { + this.highlightMarker(activeMarkerId); + }) + } + + if (this.activeMarkerId && this.activeMarkerId !== activeMarkerId) { + this.unHighlightMarker(this.activeMarkerId); + } + + this.activeMarkerId = activeMarkerId; + // setTimeout(() => { + // this.highlightMarker(this.getMarkerId(marker)); + // }, 0) + }; + + highlightMarker(markerId: string) { + const markerEls = this.editor + .getWrapperElement() + .querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${markerId}"]`); + + markerEls.forEach((el) => { + el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, true); + }); + } + + unHighlightMarker(markerId: string) { + const markerEls = this.editor + .getWrapperElement() + .querySelectorAll(`[${EDITABLE_MARKER_ATTR_ID}="${markerId}"]`); + markerEls.forEach((el) => { + el.classList.toggle(EDITABLE_MARKER_ACTIVE_CLASSNAME, false); + }); + } + + findActiveMarker(markers: CodeMirror.TextMarker[]) { + return markers.find((marker) => this.getMarkerId(marker)); + } + + getMarkerValue(marker: CodeMirror.TextMarker) { + const { from, to } = marker.find(); + return this.editor.getRange(from, to); + } + + getMarkerId(marker: CodeMirror.TextMarker) { + // @ts-expect-error not fully typed + return marker.attributes?.[EDITABLE_MARKER_ATTR_ID]; + } +} diff --git a/apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts b/apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts new file mode 100644 index 000000000..aea2fa358 --- /dev/null +++ b/apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts @@ -0,0 +1,5 @@ +export abstract class TextEditorPlugin { + abstract name: string; + abstract initialize(editor: CodeMirror.Editor): void; + abstract destroy(): void; +} diff --git a/apps/studio/src/lib/editor/utils.ts b/apps/studio/src/lib/editor/utils.ts index 4c3dcd8af..2c1a990e1 100644 --- a/apps/studio/src/lib/editor/utils.ts +++ b/apps/studio/src/lib/editor/utils.ts @@ -1,8 +1,9 @@ import * as CodeMirrorPlugins from "@/lib/editor/CodeMirrorPlugins"; import { TableOrView } from "../db/models"; +import CodeMirror from "codemirror"; export interface EditorRange { - id?: string + id?: string; from: { line: number; ch: number }; to: { line: number; ch: number }; } @@ -19,6 +20,17 @@ export interface LineGutter { type: "changed"; } +/** Checks if `target` is within `container` */ +export function isPositionWithin( + target: { from: CodeMirror.Position; to: CodeMirror.Position }, + container: { from: CodeMirror.Position; to: CodeMirror.Position } +) { + return ( + CodeMirror.cmpPos(target.from, container.from) >= 0 && + CodeMirror.cmpPos(target.to, container.to) <= 0 + ); +} + export const plugins = { autoquote: CodeMirrorPlugins.registerAutoquote, autoComplete: CodeMirrorPlugins.registerAutoComplete, From fe98f22979cd75bd95d8ec3010afd61c3279aacb Mon Sep 17 00:00:00 2001 From: azmy60 Date: Fri, 7 Mar 2025 19:28:59 +0700 Subject: [PATCH 05/12] style --- .../styles/components/detail-view-sidebar.scss | 2 +- .../text-editor-plugins/partial-read-only.scss | 13 +++++++++++++ .../src/assets/styles/components/text-editor.scss | 6 ++---- .../src/lib/editor/plugins/PartialReadOnlyPlugin.ts | 4 ++++ 4 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 apps/studio/src/assets/styles/components/text-editor-plugins/partial-read-only.scss diff --git a/apps/studio/src/assets/styles/components/detail-view-sidebar.scss b/apps/studio/src/assets/styles/components/detail-view-sidebar.scss index 90ea92dc7..45221b3c6 100644 --- a/apps/studio/src/assets/styles/components/detail-view-sidebar.scss +++ b/apps/studio/src/assets/styles/components/detail-view-sidebar.scss @@ -85,7 +85,7 @@ div.sidebar.detail-view-sidebar { } } - .CodeMirror.text-editor { + .text-editor { width: 100%; &, .CodeMirror-gutters { diff --git a/apps/studio/src/assets/styles/components/text-editor-plugins/partial-read-only.scss b/apps/studio/src/assets/styles/components/text-editor-plugins/partial-read-only.scss new file mode 100644 index 000000000..91906d592 --- /dev/null +++ b/apps/studio/src/assets/styles/components/text-editor-plugins/partial-read-only.scss @@ -0,0 +1,13 @@ +.CodeMirror.text-editor.bks-partial-read-only { + .CodeMirror-lines { + cursor: default; + } + + .bks-editable-marker { + cursor: text; + } + + .bks-editable-marker:hover, .bks-editable-marker-active { + background-color: rgba($theme-base, 0.08); + } +} diff --git a/apps/studio/src/assets/styles/components/text-editor.scss b/apps/studio/src/assets/styles/components/text-editor.scss index 626a58925..eea87c28a 100644 --- a/apps/studio/src/assets/styles/components/text-editor.scss +++ b/apps/studio/src/assets/styles/components/text-editor.scss @@ -1,3 +1,5 @@ +@import "./text-editor-plugins/partial-read-only.scss"; + .text-editor { &.remove-json-root-brackets .CodeMirror-code { & > div:first-child, & > div:last-child { @@ -38,10 +40,6 @@ } } - .bks-editable-marker:hover, .bks-editable-marker-active { - background-color: rgba($theme-base, 0.08); - } - .bks-error-marker { text-decoration-line: underline; text-decoration-style: wavy; diff --git a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts index 549f792fc..6e10a09bd 100644 --- a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts +++ b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts @@ -8,6 +8,7 @@ import { TextEditorPlugin } from "./TextEditorPlugin"; import CodeMirror from "codemirror"; // FIXME (azmi): maybe use json-ource-map instead? +const PARTIAL_READ_ONLY_CLASSNAME = "bks-partial-read-only"; const EDITABLE_MARKER_CLASSNAME = "bks-editable-marker"; const EDITABLE_MARKER_ACTIVE_CLASSNAME = "bks-editable-marker-active"; const EDITABLE_MARKER_ATTR_ID = "data-bks-editable-id"; @@ -44,6 +45,8 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { this.editor = editor; + this.editor.getWrapperElement().classList.add(PARTIAL_READ_ONLY_CLASSNAME); + this.ranges.forEach((range) => { const marker = editor.markText(range.from, range.to, { className: EDITABLE_MARKER_CLASSNAME, @@ -70,6 +73,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { this.editor.off("beforeChange", this.handleBeforeChange); this.editor.off("change", this.handleChange); this.editor.off("cursorActivity", this.handleCursorActivity); + this.editor.getWrapperElement().classList.remove(PARTIAL_READ_ONLY_CLASSNAME); } } From b65d3463f720477084bfb0098d9f95672173c5b6 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Fri, 7 Mar 2025 23:17:04 +0700 Subject: [PATCH 06/12] disable foreign key from being editable --- apps/studio/src/components/tableview/TableTable.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/components/tableview/TableTable.vue b/apps/studio/src/components/tableview/TableTable.vue index 1062032e7..4d73fd8c8 100644 --- a/apps/studio/src/components/tableview/TableTable.vue +++ b/apps/studio/src/components/tableview/TableTable.vue @@ -768,7 +768,7 @@ export default Vue.extend({ const paths = [] for (const column of this.table.columns) { - if(this.isPrimaryKey(column.columnName) || this.isGeneratedColumn(column.columnName)) { + if(this.isPrimaryKey(column.columnName) || this.isForeignKey(column.columnName) || this.isGeneratedColumn(column.columnName)) { continue } paths.push(column.columnName) @@ -937,6 +937,10 @@ export default Vue.extend({ isPrimaryKey(column) { return this.primaryKeys?.includes(column); }, + isForeignKey(column: string) { + const keyDatas: any[] = Object.entries(this.tableKeys).filter((entry) => entry[0] === column); + return keyDatas && keyDatas.length > 0 + }, isGeneratedColumn(columnName: string) { const column: ExtendedTableColumn = this.table.columns.find((col: ExtendedTableColumn) => col.columnName === columnName); return column && column.generated; From bc59a6355879305d999d2367b88ad46aa73069a9 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Thu, 13 Mar 2025 21:53:24 +0700 Subject: [PATCH 07/12] update json sidebar when table table is edited --- apps/studio/src/components/tableview/TableTable.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/studio/src/components/tableview/TableTable.vue b/apps/studio/src/components/tableview/TableTable.vue index 4d73fd8c8..dc76beb1d 100644 --- a/apps/studio/src/components/tableview/TableTable.vue +++ b/apps/studio/src/components/tableview/TableTable.vue @@ -1318,6 +1318,7 @@ export default Vue.extend({ let pendingUpdates = _.reject(this.pendingChanges.updates, { 'key': payload.key }) pendingUpdates.push(payload) this.$set(this.pendingChanges, 'updates', pendingUpdates) + this.updateDetailView() } }, cloneSelection(range?: RangeComponent) { From 2cf43f7b6ef019ea97f60b6d6a002a471b1f8744 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Wed, 19 Mar 2025 06:35:52 +0700 Subject: [PATCH 08/12] should highlight the entire text marker when hovering --- .../editor/plugins/PartialReadOnlyPlugin.ts | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts index 6e10a09bd..baad0e487 100644 --- a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts +++ b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts @@ -34,6 +34,8 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { this.handleBeforeChange = this.handleBeforeChange.bind(this); this.handleChange = this.handleChange.bind(this); this.handleCursorActivity = this.handleCursorActivity.bind(this); + this.handleMouseOver = this.handleMouseOver.bind(this); + this.handleMouseOut = this.handleMouseOut.bind(this); } initialize(editor: CodeMirror.Editor) { @@ -62,18 +64,59 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { }); }); + // Add event listeners for hover effects on all markers + setTimeout(() => { + this.setupMarkerHoverHandlers(); + }, 0); + this.editor.on("beforeChange", this.handleBeforeChange); this.editor.on("change", this.handleChange); this.editor.on("cursorActivity", this.handleCursorActivity); } + setupMarkerHoverHandlers(): void { + const wrapper = this.editor.getWrapperElement(); + + // Use event delegation for better performance + wrapper.addEventListener("mouseover", this.handleMouseOver); + wrapper.addEventListener("mouseout", this.handleMouseOut); + } + + private handleMouseOver(e: MouseEvent) { + const target = e.target as HTMLElement; + if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) { + const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID); + if (markerId) { + this.highlightMarker(markerId); + } + } + } + + private handleMouseOut(e: MouseEvent) { + const target = e.target as HTMLElement; + if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) { + const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID); + if (markerId && markerId !== this.activeMarkerId) { + this.unHighlightMarker(markerId); + } + } + } + destroy(): void { this.rangeMap.forEach((range) => range.marker.clear()); if (this.editor) { this.editor.off("beforeChange", this.handleBeforeChange); this.editor.off("change", this.handleChange); this.editor.off("cursorActivity", this.handleCursorActivity); - this.editor.getWrapperElement().classList.remove(PARTIAL_READ_ONLY_CLASSNAME); + + // Remove event listeners + const wrapper = this.editor.getWrapperElement(); + wrapper.removeEventListener("mouseover", this.handleMouseOver); + wrapper.removeEventListener("mouseout", this.handleMouseOut); + + this.editor + .getWrapperElement() + .classList.remove(PARTIAL_READ_ONLY_CLASSNAME); } } @@ -102,7 +145,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { handleChange( cm: CodeMirror.Editor, changeObj: CodeMirror.EditorChangeLinkedList - ){ + ) { // Skip the whole process if there is no callback if (!this.onEditableRangeChange) { return; @@ -119,7 +162,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { this.onEditableRangeChange(range, value); } } - }; + } handleCursorActivity(cm: CodeMirror.Editor) { const cursor = cm.getCursor(); @@ -132,7 +175,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { activeMarkerId = this.getMarkerId(activeMarker); setTimeout(() => { this.highlightMarker(activeMarkerId); - }) + }); } if (this.activeMarkerId && this.activeMarkerId !== activeMarkerId) { @@ -140,10 +183,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { } this.activeMarkerId = activeMarkerId; - // setTimeout(() => { - // this.highlightMarker(this.getMarkerId(marker)); - // }, 0) - }; + } highlightMarker(markerId: string) { const markerEls = this.editor From d882f49b13af4257eb1d3cf65ac8bdb391febd1b Mon Sep 17 00:00:00 2001 From: azmy60 Date: Wed, 19 Mar 2025 07:06:27 +0700 Subject: [PATCH 09/12] fix error markers --- .../src/components/common/texteditor/TextEditor.vue | 1 + .../src/components/sidebar/DetailViewSidebar.vue | 12 +++++------- .../src/lib/editor/plugins/PartialReadOnlyPlugin.ts | 11 +++++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/studio/src/components/common/texteditor/TextEditor.vue b/apps/studio/src/components/common/texteditor/TextEditor.vue index 71d8d94cb..835d6f1d2 100644 --- a/apps/studio/src/components/common/texteditor/TextEditor.vue +++ b/apps/studio/src/components/common/texteditor/TextEditor.vue @@ -399,6 +399,7 @@ export default { if (marker.type === "error") { markInstance = this.editor.markText(marker.from, marker.to, { className: "bks-error-marker", + attributes: { title: marker.message }, }); } else if (marker.type === "highlight") { markInstance = this.editor.markText(marker.from, marker.to, { diff --git a/apps/studio/src/components/sidebar/DetailViewSidebar.vue b/apps/studio/src/components/sidebar/DetailViewSidebar.vue index aa4551eb0..177e5a70a 100644 --- a/apps/studio/src/components/sidebar/DetailViewSidebar.vue +++ b/apps/studio/src/components/sidebar/DetailViewSidebar.vue @@ -240,13 +240,11 @@ export default Vue.extend({ log.warn(e); } }) - _.forEach(this.editableRangeErrors, ({ id, error }) => { - const line = findKeyPosition(this.text, [id]); - const { from, to } = findValueInfo(this.lines[line]); + _.forEach(this.editableRangeErrors, ({ error, from, to }) => { markers.push({ type: "error", - from: { line, ch: from }, - to: { line, ch: to }, + from, + to, message: error.message, }); }) @@ -341,12 +339,12 @@ export default Vue.extend({ this.$emit("close") }, handleEditableRangeChange: _.debounce(function (range, value) { + this.editableRangeErrors = [] try { const parsed = JSON.parse(value) - this.editableRangeErrors = [] this.$emit("bks-json-value-change", {key: range.id, value: parsed}); } catch (error) { - this.editableRangeErrors.push({ id: range.id, error }) + this.editableRangeErrors.push({ id: range.id, error, from: range.from, to: range.to }) } }, 250), }, diff --git a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts index baad0e487..26357e9b8 100644 --- a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts +++ b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts @@ -154,10 +154,17 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { if (changeObj.origin !== "setValue") { const markers = cm.findMarksAt(changeObj.from); for (const marker of markers) { - if (!this.getMarkerId(marker)) { + const id = this.getMarkerId(marker) + if (!id) { continue; } - const { range } = this.rangeMap.get(this.getMarkerId(marker)); + + const markerPos = marker.find(); + const range: EditorRange = { + id, + from: markerPos.from, + to: markerPos.to, + } const value = this.getMarkerValue(marker); this.onEditableRangeChange(range, value); } From eb961014d49dc60d9d343cf41a5861a1ae8d0c35 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Wed, 19 Mar 2025 08:07:17 +0700 Subject: [PATCH 10/12] disable replace/replace all --- .../common/texteditor/TextEditor.vue | 20 ++++- .../components/sidebar/DetailViewSidebar.vue | 6 +- .../editor/plugins/PartialReadOnlyPlugin.ts | 82 ++++++++++--------- .../lib/editor/plugins/TextEditorPlugin.ts | 11 +++ 4 files changed, 74 insertions(+), 45 deletions(-) diff --git a/apps/studio/src/components/common/texteditor/TextEditor.vue b/apps/studio/src/components/common/texteditor/TextEditor.vue index 835d6f1d2..3aaeeef4f 100644 --- a/apps/studio/src/components/common/texteditor/TextEditor.vue +++ b/apps/studio/src/components/common/texteditor/TextEditor.vue @@ -260,7 +260,6 @@ export default { gutters, // @ts-expect-error not fully typed foldGutter, - autoRefresh: true, // Remove JSON root key from folding ...(this.removeJsonRootBrackets && { foldGutter: { @@ -300,6 +299,10 @@ export default { cm.addKeyMap(this.extraKeybindings); } + if (this.readOnly) { + cm.setOption("readOnly", this.readOnly); + } + if (this.lineWrapping) { cm.setOption("lineWrapping", this.lineWrapping); } @@ -594,9 +597,18 @@ export default { menu.options = menu.options.filter((option) => !option.write); } - const customOptions = this.contextMenuOptions - ? this.contextMenuOptions(event, menu.options) - : undefined; + let customOptions: typeof menu.options | false | undefined; + + for (const plugin of this.initializedPlugins) { + if (plugin.beforeOpeningContextMenu) { + customOptions = plugin.beforeOpeningContextMenu(event, menu.options) + } + } + + // FIXME remove this in favor of plugin + if (this.contextMenuOptions) { + customOptions = this.contextMenuOptions(event, customOptions || menu.options); + } if (customOptions === false) { return; diff --git a/apps/studio/src/components/sidebar/DetailViewSidebar.vue b/apps/studio/src/components/sidebar/DetailViewSidebar.vue index 177e5a70a..46fb18037 100644 --- a/apps/studio/src/components/sidebar/DetailViewSidebar.vue +++ b/apps/studio/src/components/sidebar/DetailViewSidebar.vue @@ -54,7 +54,6 @@
No Data @@ -115,6 +115,10 @@ export default Vue.extend({ unfoldAll: 0, restoredTruncatedPaths: [], editableRangeErrors: [], + disableReplaceKeybindings: { + [this.cmCtrlOrCmd("R")]: () => false, + [this.cmCtrlOrCmd("Shift-R")]: () => false, + }, }; }, watch: { diff --git a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts index 26357e9b8..34c2716dd 100644 --- a/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts +++ b/apps/studio/src/lib/editor/plugins/PartialReadOnlyPlugin.ts @@ -4,7 +4,7 @@ import _ from "lodash"; import { EditorRange, isPositionWithin } from "@/lib/editor/utils"; -import { TextEditorPlugin } from "./TextEditorPlugin"; +import { MenuOption, TextEditorPlugin } from "./TextEditorPlugin"; import CodeMirror from "codemirror"; // FIXME (azmi): maybe use json-ource-map instead? @@ -64,42 +64,11 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { }); }); - // Add event listeners for hover effects on all markers - setTimeout(() => { - this.setupMarkerHoverHandlers(); - }, 0); - this.editor.on("beforeChange", this.handleBeforeChange); this.editor.on("change", this.handleChange); this.editor.on("cursorActivity", this.handleCursorActivity); - } - setupMarkerHoverHandlers(): void { - const wrapper = this.editor.getWrapperElement(); - - // Use event delegation for better performance - wrapper.addEventListener("mouseover", this.handleMouseOver); - wrapper.addEventListener("mouseout", this.handleMouseOut); - } - - private handleMouseOver(e: MouseEvent) { - const target = e.target as HTMLElement; - if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) { - const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID); - if (markerId) { - this.highlightMarker(markerId); - } - } - } - - private handleMouseOut(e: MouseEvent) { - const target = e.target as HTMLElement; - if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) { - const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID); - if (markerId && markerId !== this.activeMarkerId) { - this.unHighlightMarker(markerId); - } - } + this.setupMarkerHoverHandlers(); } destroy(): void { @@ -109,10 +78,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { this.editor.off("change", this.handleChange); this.editor.off("cursorActivity", this.handleCursorActivity); - // Remove event listeners - const wrapper = this.editor.getWrapperElement(); - wrapper.removeEventListener("mouseover", this.handleMouseOver); - wrapper.removeEventListener("mouseout", this.handleMouseOut); + this.removeMarkerHoverHandlers(); this.editor .getWrapperElement() @@ -120,7 +86,23 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { } } - handleBeforeChange( + beforeOpeningContextMenu(_event: MouseEvent, menuOptions: MenuOption[]) { + return menuOptions.filter((option) => !option.write); + } + + private setupMarkerHoverHandlers(): void { + const wrapper = this.editor.getWrapperElement(); + wrapper.addEventListener("mouseover", this.handleMouseOver); + wrapper.addEventListener("mouseout", this.handleMouseOut); + } + + private removeMarkerHoverHandlers(): void { + const wrapper = this.editor.getWrapperElement(); + wrapper.removeEventListener("mouseover", this.handleMouseOver); + wrapper.removeEventListener("mouseout", this.handleMouseOut); + } + + private handleBeforeChange( cm: CodeMirror.Editor, changeObj: CodeMirror.EditorChangeCancellable ) { @@ -142,7 +124,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { } } - handleChange( + private handleChange( cm: CodeMirror.Editor, changeObj: CodeMirror.EditorChangeLinkedList ) { @@ -171,7 +153,7 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { } } - handleCursorActivity(cm: CodeMirror.Editor) { + private handleCursorActivity(cm: CodeMirror.Editor) { const cursor = cm.getCursor(); const markers = cm.findMarksAt(cursor); const activeMarker = this.findActiveMarker(markers); @@ -192,6 +174,26 @@ export default class PartialReadOnlyPlugin extends TextEditorPlugin { this.activeMarkerId = activeMarkerId; } + private handleMouseOver(e: MouseEvent) { + const target = e.target as HTMLElement; + if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) { + const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID); + if (markerId) { + this.highlightMarker(markerId); + } + } + } + + private handleMouseOut(e: MouseEvent) { + const target = e.target as HTMLElement; + if (target.classList.contains(EDITABLE_MARKER_CLASSNAME)) { + const markerId = target.getAttribute(EDITABLE_MARKER_ATTR_ID); + if (markerId && markerId !== this.activeMarkerId) { + this.unHighlightMarker(markerId); + } + } + } + highlightMarker(markerId: string) { const markerEls = this.editor .getWrapperElement() diff --git a/apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts b/apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts index aea2fa358..7d38cec79 100644 --- a/apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts +++ b/apps/studio/src/lib/editor/plugins/TextEditorPlugin.ts @@ -1,5 +1,16 @@ +export interface MenuOption { + name: string; + handler: Function; + shortcut: string; + write: boolean; +} + export abstract class TextEditorPlugin { abstract name: string; abstract initialize(editor: CodeMirror.Editor): void; abstract destroy(): void; + beforeOpeningContextMenu?( + event: MouseEvent, + menuOptions: MenuOption[] + ): void | false | MenuOption[]; } From ec12279d0f8702a9cd11242a5468ad34638599d3 Mon Sep 17 00:00:00 2001 From: azmy60 Date: Wed, 19 Mar 2025 08:10:03 +0700 Subject: [PATCH 11/12] fix not found row --- apps/studio/src/components/tableview/TableTable.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/studio/src/components/tableview/TableTable.vue b/apps/studio/src/components/tableview/TableTable.vue index dc76beb1d..60863f1db 100644 --- a/apps/studio/src/components/tableview/TableTable.vue +++ b/apps/studio/src/components/tableview/TableTable.vue @@ -1894,8 +1894,7 @@ export default Vue.extend({ } }, handleJsonValueChange({key, value}) { - console.log('handleJsonValueChange', key, value) - this.tabulator.getRow(this.selectedRowIndex).getCell(key).setValue(value) + this.selectedRow?.getCell(key).setValue(value) }, debouncedSaveTab: _.debounce(function(tab) { this.$store.dispatch('tabs/save', tab) From ff936cd63c9cfba97d984fce0ba7fbd89423875b Mon Sep 17 00:00:00 2001 From: azmy60 Date: Wed, 2 Apr 2025 20:39:58 +0700 Subject: [PATCH 12/12] fix json viewer listener --- apps/studio/src/common/AppEvent.ts | 1 + apps/studio/src/components/sidebar/JsonViewerSidebar.vue | 8 +++++++- apps/studio/src/components/tableview/TableTable.vue | 9 ++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/common/AppEvent.ts b/apps/studio/src/common/AppEvent.ts index bae622fe7..ac126e11a 100644 --- a/apps/studio/src/common/AppEvent.ts +++ b/apps/studio/src/common/AppEvent.ts @@ -60,6 +60,7 @@ export enum AppEvent { switchUserKeymap = 'switchUserKeymap', updateJsonViewerSidebar = 'updateJsonViewerSidebar', jsonViewerSidebarExpandPath = 'jsonViewerSidebarExpandPath', + jsonViewerSidebarValueChange = 'jsonViewerSidebarValueChange', switchingTab = 'switchingTab', switchedTab = 'switchedTab', } diff --git a/apps/studio/src/components/sidebar/JsonViewerSidebar.vue b/apps/studio/src/components/sidebar/JsonViewerSidebar.vue index a688c58c2..52d552a22 100644 --- a/apps/studio/src/components/sidebar/JsonViewerSidebar.vue +++ b/apps/studio/src/components/sidebar/JsonViewerSidebar.vue @@ -7,6 +7,8 @@ :reinitialize="reinitializeJsonViewer" :signs="signs" :binary-encoding="$bksConfig.ui.general.binaryEncoding" + :editable-paths="editablePaths" + @bks-json-value-change="handleJsonValueChange" @expandPath="handleExpandPath" /> @@ -30,6 +32,7 @@ export default Vue.extend({ return { value: {}, expandablePaths: [], + editablePaths: [], signs: {}, }; }, @@ -54,7 +57,10 @@ export default Vue.extend({ handleExpandPath(expandablePaths: string[]) { this.trigger(AppEvent.jsonViewerSidebarExpandPath, expandablePaths) }, - update(options: { value: Record; expandablePaths: string[], signs: Record}) { + handleJsonValueChange(detail: { key: string; value: unknown }) { + this.trigger(AppEvent.jsonViewerSidebarValueChange, detail) + }, + update(options: { value: Record; expandablePaths: string[], signs: Record, editablePaths: string[] }) { this.value = options.value this.expandablePaths = options.expandablePaths this.signs = options.signs diff --git a/apps/studio/src/components/tableview/TableTable.vue b/apps/studio/src/components/tableview/TableTable.vue index 751536d4c..bb8afcdb3 100644 --- a/apps/studio/src/components/tableview/TableTable.vue +++ b/apps/studio/src/components/tableview/TableTable.vue @@ -1891,6 +1891,10 @@ export default Vue.extend({ event: AppEvent.jsonViewerSidebarExpandPath, handler: this.expandForeignKey, }, + { + event: AppEvent.jsonViewerSidebarValueChange, + handler: this.handleJsonValueChange, + }, ]) }, handleTabInactive() { @@ -1899,9 +1903,12 @@ export default Vue.extend({ event: AppEvent.jsonViewerSidebarExpandPath, handler: this.expandForeignKey, }, + { + event: AppEvent.jsonViewerSidebarValueChange, + handler: this.handleJsonValueChange, + }, ]) }, - // FIXME use appevent handleJsonValueChange({key, value}) { this.selectedRow?.getCell(key).setValue(value) },