From b14b558a6e384ae39f698d209e19522f8525aad1 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Tue, 11 Aug 2026 18:59:35 -0400 Subject: [PATCH 01/14] WIP: extract kind mapping into webview-safe kindMap module Factor the Class->Kind maps out of DataNode/MatlabVariableNode into a pure kindMap.ts with no model deps, so the drag/drop tooltip can render Kind labels without a live model. Groundwork for drag-and-drop. Co-Authored-By: Claude Opus 4.8 --- src/dex/datamodel/kindMap.ts | 92 +++++++++++++++++++ src/dex/datamodel/node/DataNode.ts | 60 +----------- .../datamodel/node/data/MatlabVariableNode.ts | 3 +- 3 files changed, 95 insertions(+), 60 deletions(-) create mode 100644 src/dex/datamodel/kindMap.ts diff --git a/src/dex/datamodel/kindMap.ts b/src/dex/datamodel/kindMap.ts new file mode 100644 index 0000000..83b6309 --- /dev/null +++ b/src/dex/datamodel/kindMap.ts @@ -0,0 +1,92 @@ +// Copyright 2026 The MathWorks, Inc. +// +// The Class → user-facing Kind mapping, factored out of DataNode so it has NO +// model dependencies and can be bundled into the webview (the drag/drop tooltip +// needs human-readable Kind labels — "Bus", "Data Interface" — without a live +// model). Pure data + one pure function; safe on both host and client. + +// The user-facing Kind for each object Class. This is the friendly name shown in +// the Kind column (e.g. Class 'Simulink.Bus' → Kind 'Bus'); it never appears in +// the Class column (raw class identity) or the Data Type column (a real data +// type). A class absent from this map falls back to its raw class name. +export const KIND_BY_CLASS: Record = { + 'Simulink.Parameter': 'Simulink Parameter', + 'Simulink.Signal': 'Simulink Signal', + 'Simulink.LookupTable': 'Lookup Table', + 'Simulink.Breakpoint': 'Breakpoint', + 'Simulink.Bus': 'Bus', + 'Simulink.BusElement': 'Bus Element', + 'Simulink.ConnectionBus': 'Connection Bus', + 'Simulink.ConnectionElement': 'Connection Element', + 'Simulink.ServiceBus': 'Service Interface', + 'Simulink.FunctionElement': 'Function Element', + 'Simulink.ValueType': 'Value Type', + 'Simulink.AliasType': 'Alias Type', + 'Simulink.NumericType': 'Numeric Type', + 'Simulink.data.dictionary.EnumTypeDefinition': 'Enumerated Type', + 'Simulink.VariantExpression': 'Variant Expression', + 'Simulink.VariantControl': 'Variant Control', + 'Simulink.VariantVariable': 'Variant Variable', + 'Simulink.VariantBank': 'Variant Bank', + 'Simulink.VariantBankCoderInfo': 'Variant Bank Coder Info', + 'Simulink.VariantConfigurationData': 'Variant Configuration', + 'Simulink.VariantConfigurations': 'Variant Configuration', + 'Simulink.ConfigSet': 'Configuration Set', + 'Simulink.ConfigSetRef': 'Configuration Reference', +}; + +// The default user-facing Kind for a DERIVED (architectural) entry when the +// SystemComposer catalog doesn't classify it — e.g. a freshly pasted entry, whose +// new name isn't in the catalog. Architectural data stores interfaces as ordinary +// Simulink objects, so the same Class means a different Kind there: a derived +// Simulink.Bus is a Data Interface, a derived Simulink.ConnectionBus a Physical +// Interface. Classes whose Kind is identical in both sections (e.g. +// Simulink.ServiceBus → 'Service Interface', value/numeric/alias types) are +// omitted — KIND_BY_CLASS already yields the right label for them. +export const DERIVED_KIND_BY_CLASS: Record = { + 'Simulink.Bus': 'Data Interface', + 'Simulink.ConnectionBus': 'Physical Interface', +}; + +// The user-facing Kind for each semantic classification token. Some entries are +// classified (via the systemcomposer catalog) into a Kind that comes from the +// classification rather than the Class, so the same Simulink.Bus can be a 'Data +// Interface' or a 'Struct Type' depending on how the catalog models it. +export const KIND_BY_CLASSIFICATION: Record = { + DataInterface: 'Data Interface', + PhysicalInterface: 'Physical Interface', + ServiceInterface: 'Service Interface', + ValueType: 'Value Type', + StructType: 'Struct Type', + NumericType: 'Numeric Type', + EnumType: 'Enumerated Type', + AliasType: 'Alias Type', +}; + +// The Kind a plain MATLAB variable (className 'double', 'int8', a struct, etc.) +// shows: 'MATLAB Variable' in Design Data, 'Constant' when derived (arch). A +// MATLAB variable has no entry in KIND_BY_CLASS (its className is a raw data +// type), so this is handled separately from the object-class path. +export function matlabVariableKind(isDerived: boolean): string { + return isDerived ? 'Constant' : 'MATLAB Variable'; +} + +// Resolve the user-facing Kind for a Class in a given section context, WITHOUT a +// live node — used by the webview drag/drop tooltip. `classification` (when the +// catalog classified the source) wins; then a derived object class takes its arch +// default; else the plain class map; else the raw class name. `isMatlabVariable` +// routes 'double'/struct/etc. through matlabVariableKind. Mirrors DataNode.kind +// and MatlabVariableNode.kind so the tooltip label matches the Kind column. +export function kindForClass( + className: string, + opts: { isDerived?: boolean; isMatlabVariable?: boolean; classification?: string } = {}, +): string { + const { isDerived = false, isMatlabVariable = false, classification } = opts; + if (isMatlabVariable) { + if (classification) return KIND_BY_CLASSIFICATION[classification] || classification; + return matlabVariableKind(isDerived); + } + if (classification) return KIND_BY_CLASSIFICATION[classification] || classification; + if (isDerived && DERIVED_KIND_BY_CLASS[className]) return DERIVED_KIND_BY_CLASS[className]; + return KIND_BY_CLASS[className] || className; +} diff --git a/src/dex/datamodel/node/DataNode.ts b/src/dex/datamodel/node/DataNode.ts index 368b444..d6577f7 100644 --- a/src/dex/datamodel/node/DataNode.ts +++ b/src/dex/datamodel/node/DataNode.ts @@ -1,6 +1,7 @@ // Copyright 2026 The MathWorks, Inc. import BaseNode from './BaseNode'; +import { KIND_BY_CLASS, DERIVED_KIND_BY_CLASS, KIND_BY_CLASSIFICATION } from '../kindMap'; import { escapeXml, formatDoubleXml, @@ -57,65 +58,6 @@ export interface SetPropertyResult { validValue: string; } -// The user-facing Kind for each object Class. This is the friendly name shown -// in the Kind column (e.g. Class 'Simulink.Bus' → Kind 'Bus'); it never appears -// in the Class column (which shows the raw class identity) or the Data Type -// column (which shows a real data type). A class absent from this map falls back -// to its raw class name as its Kind. -const KIND_BY_CLASS: Record = { - 'Simulink.Parameter': 'Simulink Parameter', - 'Simulink.Signal': 'Simulink Signal', - 'Simulink.LookupTable': 'Lookup Table', - 'Simulink.Breakpoint': 'Breakpoint', - 'Simulink.Bus': 'Bus', - 'Simulink.BusElement': 'Bus Element', - 'Simulink.ConnectionBus': 'Connection Bus', - 'Simulink.ConnectionElement': 'Connection Element', - 'Simulink.ServiceBus': 'Service Interface', - 'Simulink.FunctionElement': 'Function Element', - 'Simulink.ValueType': 'Value Type', - 'Simulink.AliasType': 'Alias Type', - 'Simulink.NumericType': 'Numeric Type', - 'Simulink.data.dictionary.EnumTypeDefinition': 'Enumerated Type', - 'Simulink.VariantExpression': 'Variant Expression', - 'Simulink.VariantControl': 'Variant Control', - 'Simulink.VariantVariable': 'Variant Variable', - 'Simulink.VariantBank': 'Variant Bank', - 'Simulink.VariantBankCoderInfo': 'Variant Bank Coder Info', - 'Simulink.VariantConfigurationData': 'Variant Configuration', - 'Simulink.VariantConfigurations': 'Variant Configuration', - 'Simulink.ConfigSet': 'Configuration Set', - 'Simulink.ConfigSetRef': 'Configuration Reference', -}; - -// The default user-facing Kind for a DERIVED (architectural) entry when the -// SystemComposer catalog doesn't classify it — e.g. a freshly pasted entry, -// whose new name isn't in the catalog. Architectural data stores interfaces as -// ordinary Simulink objects, so the same Class means a different Kind there: a -// derived Simulink.Bus is a Data Interface, a derived Simulink.ConnectionBus a -// Physical Interface. Classes whose Kind is identical in both sections (e.g. -// Simulink.ServiceBus -> 'Service Interface', value/numeric/alias types) are -// omitted — KIND_BY_CLASS already yields the right label for them. -const DERIVED_KIND_BY_CLASS: Record = { - 'Simulink.Bus': 'Data Interface', - 'Simulink.ConnectionBus': 'Physical Interface', -}; - -// The user-facing Kind for each semantic classification token. Some entries are -// classified (via the systemcomposer catalog) into a Kind that comes from the -// classification rather than the Class, so the same Simulink.Bus can be a -// 'Data Interface' or a 'Struct Type' depending on how the catalog models it. -const KIND_BY_CLASSIFICATION: Record = { - DataInterface: 'Data Interface', - PhysicalInterface: 'Physical Interface', - ServiceInterface: 'Service Interface', - ValueType: 'Value Type', - StructType: 'Struct Type', - NumericType: 'Numeric Type', - EnumType: 'Enumerated Type', - AliasType: 'Alias Type', -}; - export default class DataNode extends BaseNode { metadata: Record | null; serial: Record; diff --git a/src/dex/datamodel/node/data/MatlabVariableNode.ts b/src/dex/datamodel/node/data/MatlabVariableNode.ts index 52611c0..b9b1adb 100644 --- a/src/dex/datamodel/node/data/MatlabVariableNode.ts +++ b/src/dex/datamodel/node/data/MatlabVariableNode.ts @@ -2,6 +2,7 @@ import DataNode from '../DataNode'; import type { SetPropertyResult } from '../DataNode'; +import { matlabVariableKind } from '../../kindMap'; import type { PropClass } from '../BaseNode'; import type BaseNode from '../BaseNode'; import * as NodeRegistry from '../NodeRegistry'; @@ -283,7 +284,7 @@ export default class MatlabVariableNode extends DataNode { if (this.classification) { return super.kind; } - return this.isDerived ? 'Constant' : 'MATLAB Variable'; + return matlabVariableKind(this.isDerived); } get nameEditable(): boolean { From 4d7ec35b2909f69fe5828fb5235e5b9255faa3e3 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Tue, 11 Aug 2026 19:04:22 -0400 Subject: [PATCH 02/14] Add pure dropDecision predictor for drag-and-drop feedback dropDecision(source, target, mode) mirrors the cut/copy-paste rules so the webview can render live drag feedback (cursor + hover tooltip) without a host round-trip on every dragover: - accept/reject mirrors pasteEntry's allow-check (empty array-class, i.e. a MATLAB variable, is never rejected); - the tooltip's Kind labels mirror the post-paste Kind via kindForClass with no classification (design Bus -> arch reads "Convert Bus to Data Interface"); - same-doc same-section move is a no-op (no delete/re-add); - any rejected item in a multi-select rejects the whole drop. Co-Authored-By: Claude Opus 4.8 --- src/webview/dropDecision.ts | 116 +++++++++++++++++++++++ test/dropDecision.test.ts | 181 ++++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 src/webview/dropDecision.ts create mode 100644 test/dropDecision.test.ts diff --git a/src/webview/dropDecision.ts b/src/webview/dropDecision.ts new file mode 100644 index 0000000..b9f5acf --- /dev/null +++ b/src/webview/dropDecision.ts @@ -0,0 +1,116 @@ +// Copyright 2026 The MathWorks, Inc. +// +// dropDecision is the PURE predictor behind drag-and-drop feedback: given the +// dragged rows (source) and the section under the cursor (target), it returns +// whether a drop is allowed, which cursor to show, and the hover tooltip. +// +// It exists so the webview can render live drag feedback WITHOUT a round-trip to +// the host on every dragover. It must mirror the host exactly: +// • accept/reject mirrors pasteEntry's allow-check — a payload's array-class +// must be in the target section's allowed types (an empty array-class, i.e. +// a plain MATLAB variable, is never rejected, just like pasteEntry). +// • the tooltip's Kind labels mirror the Kind an entry shows AFTER the paste: +// a pasted entry loses its SystemComposer classification (its new name isn't +// in the catalog), so its Kind comes from class + the target's derived flag. +// kindForClass with NO classification reproduces that post-paste Kind. +// +// The bottom line it enforces: drag-drop matches cut/copy-paste — if you can +// cut/copy you can drag, and if you can paste you can drop. +import { kindForClass } from '../dex/datamodel/kindMap.js'; + +export type DragMode = 'copy' | 'move'; +export type DropCursor = 'copy' | 'move' | 'no-drop'; + +// One dragged row, as the webview knows it (no live model). `arrayClass` is the +// serialized entry's _array_class ('' for a MATLAB variable); `className` is the +// raw class shown in the Class column; `kind` is the Kind currently displayed. +export interface DragItem { + className: string; + arrayClass: string; + kind: string; + isMatlabVariable: boolean; +} + +export interface DragSource { + docUri: string; + sectionName: string; + sectionLabel: string; + isDerived: boolean; + items: DragItem[]; +} + +export interface DropTarget { + docUri: string; + sectionName: string; + sectionLabel: string; + isDerived: boolean; + allowedTypes: string[]; +} + +export interface DropDecision { + canDrop: boolean; + cursor: DropCursor; + tooltip: string; + noop: boolean; +} + +// Mirror SectionNode.allowsType: an empty array-class (MATLAB variable) is +// always accepted; otherwise the class must be in the allow-list. An empty +// allow-list means "no restriction". +function targetAllows(target: DropTarget, item: DragItem): boolean { + if (!item.arrayClass) return true; + if (target.allowedTypes.length === 0) return true; + return target.allowedTypes.indexOf(item.arrayClass) !== -1; +} + +// The Kind an item WILL show once dropped into `target`: class + the target's +// derived flag, with NO classification (a pasted entry's new name isn't in the +// SystemComposer catalog, so it never keeps a classification-derived Kind). +function kindInTarget(item: DragItem, target: DropTarget): string { + return kindForClass(item.className, { + isDerived: target.isDerived, + isMatlabVariable: item.isMatlabVariable, + }); +} + +function reject(tooltip: string): DropDecision { + return { canDrop: false, cursor: 'no-drop', tooltip, noop: false }; +} + +export function dropDecision(source: DragSource, target: DropTarget, mode: DragMode): DropDecision { + const items = source.items ?? []; + if (items.length === 0) return reject('Nothing to drop'); + + // Same document + same section + move = reorder within one section, which we + // treat as a no-op (a move here would delete and re-add the same entry). A + // COPY into the same section is a genuine duplicate, so it is allowed. + const sameSection = source.docUri === target.docUri && source.sectionName === target.sectionName; + if (sameSection && mode === 'move') { + return { canDrop: false, cursor: 'no-drop', tooltip: '', noop: true }; + } + + // Any single rejected item rejects the whole drop (mirrors a multi-select + // paste, which is all-or-nothing). + const rejected = items.find((it) => !targetAllows(target, it)); + if (rejected) { + return reject(`${rejected.kind} cannot be in ${target.sectionLabel}`); + } + + const cursor: DropCursor = mode === 'copy' ? 'copy' : 'move'; + return { canDrop: true, cursor, tooltip: dropTooltip(items, target), noop: false }; +} + +// The hover label for an allowed drop. Single item: name the Kind and, when the +// drop changes it (design↔arch), phrase it as a conversion. Multiple items: a +// count label, still distinguishing a same-Kind copy/move from a conversion. +function dropTooltip(items: DragItem[], target: DropTarget): string { + if (items.length === 1) { + const it = items[0]; + const from = it.kind; + const to = kindInTarget(it, target); + return from === to ? `Copy/Move ${from}` : `Convert ${from} to ${to}`; + } + const converts = items.some((it) => it.kind !== kindInTarget(it, target)); + if (converts) return `Convert ${items.length} items to ${target.sectionLabel}`; + return `Copy/Move ${items.length} items`; +} diff --git a/test/dropDecision.test.ts b/test/dropDecision.test.ts new file mode 100644 index 0000000..ea966a3 --- /dev/null +++ b/test/dropDecision.test.ts @@ -0,0 +1,181 @@ +// Copyright 2026 The MathWorks, Inc. +// +// dropDecision is the PURE predictor behind the drag-and-drop cursor and hover +// tooltip. The bottom line it must honor: drag-drop mirrors cut/copy-paste — if +// you can cut/copy you can drag, and if you can paste you can drop. So its +// accept/reject logic mirrors pasteEntry's allow-check exactly, and its labels +// mirror the Kind an entry shows AFTER the paste (a pasted entry loses its +// SystemComposer classification, so a derived Bus becomes a "Data Interface"). +import { describe, it, expect } from 'vitest'; +import { dropDecision } from '../src/webview/dropDecision.js'; + +// Allow-lists mirror SectionNode.ALLOWED_TYPES (only the classes these tests use). +const DESIGN_ALLOWED = [ + 'Simulink.Signal', + 'Simulink.Bus', + 'Simulink.ConnectionBus', + 'Simulink.Parameter', + 'Simulink.ValueType', + 'Simulink.NumericType', +]; +const ARCH_ALLOWED = [ + 'Simulink.Signal', + 'Simulink.Bus', + 'Simulink.ConnectionBus', + 'Simulink.ServiceBus', + 'Simulink.ValueType', + 'Simulink.NumericType', +]; + +function designSource(items: any[], docUri = 'a.sldd') { + return { docUri, sectionName: 'design', sectionLabel: 'Design Data', isDerived: false, items }; +} +function archSource(items: any[], docUri = 'a.sldd') { + return { docUri, sectionName: 'arch', sectionLabel: 'Architectural Data', isDerived: true, items }; +} +function designTarget(docUri = 'a.sldd') { + return { + docUri, + sectionName: 'design', + sectionLabel: 'Design Data', + isDerived: false, + allowedTypes: DESIGN_ALLOWED, + }; +} +function archTarget(docUri = 'a.sldd') { + return { docUri, sectionName: 'arch', sectionLabel: 'Architectural Data', isDerived: true, allowedTypes: ARCH_ALLOWED }; +} + +// Convenience item builders. +const bus = (kind = 'Bus') => ({ className: 'Simulink.Bus', arrayClass: 'Simulink.Bus', kind, isMatlabVariable: false }); +const dataInterface = () => bus('Data Interface'); +const param = () => ({ + className: 'Simulink.Parameter', + arrayClass: 'Simulink.Parameter', + kind: 'Simulink Parameter', + isMatlabVariable: false, +}); +const service = () => ({ + className: 'Simulink.ServiceBus', + arrayClass: 'Simulink.ServiceBus', + kind: 'Service Interface', + isMatlabVariable: false, +}); +const matlabVar = (kind = 'MATLAB Variable') => ({ + className: 'double', + arrayClass: '', + kind, + isMatlabVariable: true, +}); + +describe('dropDecision — accept/reject mirrors pasteEntry allow-check', () => { + it('rejects a Simulink.Parameter dropped into Architectural Data with a reason tooltip', () => { + const d = dropDecision(designSource([param()], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(false); + expect(d.cursor).toBe('no-drop'); + expect(d.tooltip).toBe('Simulink Parameter cannot be in Architectural Data'); + }); + + it('rejects a ServiceInterface dropped into Design Data', () => { + const d = dropDecision(archSource([service()], 'b.sldd'), designTarget('a.sldd'), 'move'); + expect(d.canDrop).toBe(false); + expect(d.cursor).toBe('no-drop'); + expect(d.tooltip).toBe('Service Interface cannot be in Design Data'); + }); + + it('accepts a Bus into Architectural Data (allowed type)', () => { + const d = dropDecision(designSource([bus()], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(true); + }); + + it('accepts a MATLAB variable into any section (empty array-class skips the check)', () => { + // A MATLAB variable has no _array_class, so pasteEntry never rejects it — + // even into arch, where it becomes a Constant. + const d = dropDecision(designSource([matlabVar()], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(true); + }); +}); + +describe('dropDecision — dynamic can-drop tooltip mirrors the post-paste Kind', () => { + it('design Bus -> another design section reads "Copy/Move Bus"', () => { + const d = dropDecision(designSource([bus()], 'b.sldd'), designTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(true); + expect(d.tooltip).toBe('Copy/Move Bus'); + }); + + it('design Bus -> arch reads "Convert Bus to Data Interface"', () => { + const d = dropDecision(designSource([bus()], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(true); + expect(d.tooltip).toBe('Convert Bus to Data Interface'); + }); + + it('arch Data Interface -> design reads "Convert Data Interface to Bus"', () => { + const d = dropDecision(archSource([dataInterface()], 'b.sldd'), designTarget('a.sldd'), 'move'); + expect(d.canDrop).toBe(true); + expect(d.tooltip).toBe('Convert Data Interface to Bus'); + }); + + it('a MATLAB variable design -> arch reads "Convert MATLAB Variable to Constant"', () => { + const d = dropDecision(designSource([matlabVar()], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.tooltip).toBe('Convert MATLAB Variable to Constant'); + }); +}); + +describe('dropDecision — cursor reflects the drag mode', () => { + it('copy mode yields a copy cursor', () => { + expect(dropDecision(designSource([bus()], 'b.sldd'), archTarget('a.sldd'), 'copy').cursor).toBe('copy'); + }); + it('move mode yields a move cursor', () => { + expect(dropDecision(designSource([bus()], 'b.sldd'), designTarget('a.sldd'), 'move').cursor).toBe('move'); + }); +}); + +describe('dropDecision — same-section move is a no-op', () => { + it('same doc + same section + move is a no-op (no delete/re-add)', () => { + const d = dropDecision(designSource([bus()], 'a.sldd'), designTarget('a.sldd'), 'move'); + expect(d.noop).toBe(true); + expect(d.canDrop).toBe(false); + expect(d.cursor).toBe('no-drop'); + }); + + it('same doc + same section + COPY still duplicates (not a no-op)', () => { + const d = dropDecision(designSource([bus()], 'a.sldd'), designTarget('a.sldd'), 'copy'); + expect(d.noop).toBe(false); + expect(d.canDrop).toBe(true); + expect(d.tooltip).toBe('Copy/Move Bus'); + }); + + it('cross-doc same-section-name move is NOT a no-op', () => { + const d = dropDecision(designSource([bus()], 'a.sldd'), designTarget('b.sldd'), 'move'); + expect(d.noop).toBe(false); + expect(d.canDrop).toBe(true); + }); +}); + +describe('dropDecision — multi-select: any rejected rejects all', () => { + it('a Bus + a Parameter dropped into arch is rejected (the Parameter has no home)', () => { + const d = dropDecision(designSource([bus(), param()], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(false); + expect(d.tooltip).toBe('Simulink Parameter cannot be in Architectural Data'); + }); + + it('multiple allowed items use a count label', () => { + const d = dropDecision(designSource([bus(), bus()], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(true); + expect(d.tooltip).toBe('Convert 2 items to Architectural Data'); + }); + + it('multiple allowed same-shape items use a count label', () => { + const d = dropDecision(designSource([bus(), bus()], 'b.sldd'), designTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(true); + expect(d.tooltip).toBe('Copy/Move 2 items'); + }); +}); + +describe('dropDecision — empty payload', () => { + it('no items cannot drop', () => { + const d = dropDecision(designSource([], 'b.sldd'), archTarget('a.sldd'), 'copy'); + expect(d.canDrop).toBe(false); + expect(d.cursor).toBe('no-drop'); + }); +}); From 29dc3c35c9ab1f61d5921abd0bc2e8541e701522 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Tue, 11 Aug 2026 19:14:37 -0400 Subject: [PATCH 03/14] Add host-side drag register, drop transforms, and section rules Pure building blocks for host-mediated drag-and-drop (dataTransfer does not survive the webview iframe boundary, so the host holds the payloads like the clipboard singleton): - dragState: the drag register + a payload-free descriptor broadcast to every webview for live dragover prediction; - pasteEntries: multi-item drop completion (fold over pasteEntry, each paste sees the growing namespace; all-or-nothing allow-check); - deleteEntriesByName: source-side of a MOVE (remove dragged entries from the source text, high-offset-first, absent names skipped); - sectionRules: per-section allow-list + derived flag posted to each webview so dropDecision runs client-side. Co-Authored-By: Claude Opus 4.8 --- src/host/dragState.ts | 76 ++++++++++++++++++++++++++++++++ src/host/sectionRules.ts | 27 ++++++++++++ src/host/structuralEdit.ts | 58 ++++++++++++++++++++++++ test/deleteEntriesByName.test.ts | 52 ++++++++++++++++++++++ test/dragState.test.ts | 74 +++++++++++++++++++++++++++++++ test/dropComplete.test.ts | 72 ++++++++++++++++++++++++++++++ test/sectionRules.test.ts | 39 ++++++++++++++++ 7 files changed, 398 insertions(+) create mode 100644 src/host/dragState.ts create mode 100644 src/host/sectionRules.ts create mode 100644 test/deleteEntriesByName.test.ts create mode 100644 test/dragState.test.ts create mode 100644 test/dropComplete.test.ts create mode 100644 test/sectionRules.test.ts diff --git a/src/host/dragState.ts b/src/host/dragState.ts new file mode 100644 index 0000000..40c16ed --- /dev/null +++ b/src/host/dragState.ts @@ -0,0 +1,76 @@ +// Copyright 2026 The MathWorks, Inc. +// +// The drag register: a single module-level record of the rows currently being +// dragged, mirroring the clipboard singleton (clipboard.ts). It exists because +// HTML5 dataTransfer does not reliably survive the webview iframe boundary — so +// on drag start the host snapshots the dragged entries here, and on drop the +// target webview asks the host to complete the drop, which reads the payloads +// back from this register. Between the two, the host broadcasts a lightweight +// DESCRIPTOR (source metadata + per-item class/kind, NOT the payloads) to every +// webview so each can predict the drop live (dropDecision) on dragover. + +// One dragged entry: its serialized payload (for the eventual paste) plus the +// display facts the webview needs to render feedback without a model. +export interface DragRegisterItem { + payload: Record; + className: string; + arrayClass: string; + kind: string; + isMatlabVariable: boolean; +} + +interface DragEntry { + sourceDocUri: string; + sourceSection: string; + sourceSectionLabel: string; + sourceIsDerived: boolean; + items: DragRegisterItem[]; +} + +// The lightweight descriptor broadcast to webviews. It mirrors dropDecision's +// DragSource shape and deliberately OMITS the payloads (they can be large and +// the webview never needs them — the drop is completed host-side). +export interface DragDescriptor { + docUri: string; + sectionName: string; + sectionLabel: string; + isDerived: boolean; + items: Array<{ className: string; arrayClass: string; kind: string; isMatlabVariable: boolean }>; +} + +let current: DragEntry | null = null; + +export function setDrag( + sourceDocUri: string, + sourceSection: string, + sourceSectionLabel: string, + sourceIsDerived: boolean, + items: DragRegisterItem[], +): void { + current = { sourceDocUri, sourceSection, sourceSectionLabel, sourceIsDerived, items }; +} + +export function getDrag(): DragEntry | null { + return current; +} + +export function clearDrag(): void { + current = null; +} + +/** The payload-free descriptor posted to every webview for live drag feedback. */ +export function dragDescriptor(): DragDescriptor | null { + if (!current) return null; + return { + docUri: current.sourceDocUri, + sectionName: current.sourceSection, + sectionLabel: current.sourceSectionLabel, + isDerived: current.sourceIsDerived, + items: current.items.map((it) => ({ + className: it.className, + arrayClass: it.arrayClass, + kind: it.kind, + isMatlabVariable: it.isMatlabVariable, + })), + }; +} diff --git a/src/host/sectionRules.ts b/src/host/sectionRules.ts new file mode 100644 index 0000000..13a3e5c --- /dev/null +++ b/src/host/sectionRules.ts @@ -0,0 +1,27 @@ +// Copyright 2026 The MathWorks, Inc. +// +// sectionRules distills, from a live model, the per-section facts the webview +// needs to PREDICT a drop locally (dropDecision) without a host round-trip on +// every dragover: the section name, its human label, whether it is derived +// (architectural), and the classes it accepts. It is posted to each webview +// alongside its rows, and mirrors SectionNode's allow-list + the section→ +// isderived mapping so the webview's prediction matches what the host paste +// would actually do. +import { getSectionMetadata } from '../dex/datamodel/SectionConstants.js'; + +export interface SectionRule { + sectionName: string; + sectionLabel: string; + isDerived: boolean; + allowedTypes: string[]; +} + +export function sectionRules(model: any): SectionRule[] { + const sections = (model?.children ?? []) as any[]; + return sections.map((s) => ({ + sectionName: s.name, + sectionLabel: s.displayName ?? s.name, + isDerived: getSectionMetadata(s.name).isderived === '1', + allowedTypes: typeof s.getAllowedTypes === 'function' ? s.getAllowedTypes() : [], + })); +} diff --git a/src/host/structuralEdit.ts b/src/host/structuralEdit.ts index ad373c4..a92bcf4 100644 --- a/src/host/structuralEdit.ts +++ b/src/host/structuralEdit.ts @@ -189,3 +189,61 @@ export function pasteEntry( const newText = text.slice(0, insertion.offset) + inserted + text.slice(insertion.offset); return { newText, selectId: newNode.id }; } + +/** + * Source-side of a MOVE drop: remove the dragged entries from the SOURCE text by + * name. Works purely on text so it applies to any document (the move source may + * differ from the paste target). Each named top-level entry's array element is + * spliced out; spans are removed high-offset-first so earlier removals don't + * shift the offsets of later ones. Names not present are silently skipped, so an + * already-absent entry never throws (and an all-absent list returns the text + * unchanged, byte-identical). + */ +export function deleteEntriesByName(text: string, names: string[]): string { + const spans: { offset: number; length: number }[] = []; + for (const name of names) { + const span = findEntryElementSpan(text, name); + if (span) spans.push(span); + } + // Remove from the end so each splice leaves earlier offsets valid. + spans.sort((a, b) => b.offset - a.offset); + let out = text; + for (const span of spans) { + out = out.slice(0, span.offset) + out.slice(span.offset + span.length); + } + return out; +} + +/** + * Drop-completion transform: paste MANY payloads into `section` in one edit — + * exactly what a multi-select drop needs. It is a fold over pasteEntry: each + * paste re-inserts into the text produced by the previous one AND adds the new + * node to the live `section`, so `_uniqueName` sees the growing namespace and + * every dropped entry gets a distinct name (a first Bus becomes Bus1, a second + * Bus2). The allow-check is all-or-nothing: any disallowed payload throws before + * any text changes, so a rejected multi-drop leaves the document untouched. A + * move deletes the sources separately (the host, via deleteEntry) — this side + * is purely the paste, identical to how drop mirrors copy/cut + paste. + */ +export function pasteEntries( + text: string, + section: any, + payloads: Record[], +): { newText: string; selectIds: string[] } { + // All-or-nothing allow-check up front: reject the whole drop before mutating + // any text or the section, so a bad item can't leave a half-applied paste. + for (const payload of payloads) { + const className = payloadClassName(payload); + if (className && typeof section.allowsType === 'function' && !section.allowsType(className)) { + throw new Error(`A "${className}" entry is not allowed in ${section.displayName ?? section.name}.`); + } + } + let currentText = text; + const selectIds: string[] = []; + for (const payload of payloads) { + const { newText, selectId } = pasteEntry(currentText, section, payload); + currentText = newText; + if (selectId) selectIds.push(selectId); + } + return { newText: currentText, selectIds }; +} diff --git a/test/deleteEntriesByName.test.ts b/test/deleteEntriesByName.test.ts new file mode 100644 index 0000000..9b87be2 --- /dev/null +++ b/test/deleteEntriesByName.test.ts @@ -0,0 +1,52 @@ +// Copyright 2026 The MathWorks, Inc. +// +// deleteEntriesByName is the source-side of a MOVE drop: after the payloads are +// pasted into the target, the dragged entries are removed from the SOURCE +// document by name. It must work purely on text (the source may be a different +// document than the target), remove each named top-level entry, and leave the +// rest byte-valid — deleting several at once without offset drift. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { getModel, invalidate } from '../src/host/SlddModel.js'; +import { deleteEntriesByName } from '../src/host/structuralEdit.js'; + +const archText = readFileSync(fileURLToPath(new URL('./fixtures/arch.sldd', import.meta.url)), 'utf8'); + +describe('deleteEntriesByName', () => { + it('removes a single named entry, leaving valid JSON without it', () => { + const uri = 'test://del-one.sldd'; + invalidate(uri); + const before = getModel(uri, 'arch.sldd', archText); + const archBefore = before.children.find((s: any) => s.name === 'arch').children.map((c: any) => c.name); + expect(archBefore).toContain('DataInterface'); + + const newText = deleteEntriesByName(archText, ['DataInterface']); + expect(() => JSON.parse(newText)).not.toThrow(); + + invalidate(uri); + const after = getModel(uri, 'arch.sldd', newText); + const archAfter = after.children.find((s: any) => s.name === 'arch').children.map((c: any) => c.name); + expect(archAfter).not.toContain('DataInterface'); + }); + + it('removes multiple named entries in one pass without offset drift', () => { + const uri = 'test://del-many.sldd'; + const newText = deleteEntriesByName(archText, ['DataInterface', 'NumericType', 'ValueType']); + expect(() => JSON.parse(newText)).not.toThrow(); + + invalidate(uri); + const after = getModel(uri, 'arch.sldd', newText); + const allNames = after.children.flatMap((s: any) => s.children.map((c: any) => c.name)); + expect(allNames).not.toContain('DataInterface'); + expect(allNames).not.toContain('NumericType'); + expect(allNames).not.toContain('ValueType'); + }); + + it('ignores names that are not present (no throw, no change to others)', () => { + const newText = deleteEntriesByName(archText, ['DoesNotExist']); + expect(() => JSON.parse(newText)).not.toThrow(); + // Nothing removed → text unchanged. + expect(newText).toBe(archText); + }); +}); diff --git a/test/dragState.test.ts b/test/dragState.test.ts new file mode 100644 index 0000000..24eaebf --- /dev/null +++ b/test/dragState.test.ts @@ -0,0 +1,74 @@ +// Copyright 2026 The MathWorks, Inc. +// +// The drag register is the host-side singleton that holds the rows currently +// being dragged, mirroring the clipboard singleton. HTML5 dataTransfer does not +// survive the webview iframe boundary, so the host holds the payloads and +// broadcasts a lightweight descriptor (source metadata + per-item class/kind, +// but NOT the full payloads) to every webview, which each run dropDecision on +// dragover. Drop reads the payloads back from the register. +import { describe, it, expect, beforeEach } from 'vitest'; +import { + setDrag, + getDrag, + clearDrag, + dragDescriptor, + type DragRegisterItem, +} from '../src/host/dragState.js'; + +const item = (name: string, className: string, arrayClass: string, kind: string): DragRegisterItem => ({ + payload: { name, metadata: { uuid: 'u-' + name }, value: arrayClass ? { _array_class: arrayClass } : 1 }, + className, + arrayClass, + kind, + isMatlabVariable: !arrayClass, +}); + +describe('drag register', () => { + beforeEach(() => clearDrag()); + + it('is empty until a drag starts', () => { + expect(getDrag()).toBeNull(); + expect(dragDescriptor()).toBeNull(); + }); + + it('holds the dragged items and their source', () => { + const items = [item('Bus', 'Simulink.Bus', 'Simulink.Bus', 'Bus')]; + setDrag('a.sldd', 'design', 'Design Data', false, items); + const d = getDrag(); + expect(d).not.toBeNull(); + expect(d!.sourceDocUri).toBe('a.sldd'); + expect(d!.sourceSection).toBe('design'); + expect(d!.items).toHaveLength(1); + expect(d!.items[0].payload).toEqual(items[0].payload); + }); + + it('descriptor exposes source + per-item class/kind but NOT the payloads', () => { + setDrag('a.sldd', 'arch', 'Architectural Data', true, [ + item('DataInterface', 'Simulink.Bus', 'Simulink.Bus', 'Data Interface'), + ]); + const desc = dragDescriptor(); + expect(desc).toEqual({ + docUri: 'a.sldd', + sectionName: 'arch', + sectionLabel: 'Architectural Data', + isDerived: true, + items: [ + { + className: 'Simulink.Bus', + arrayClass: 'Simulink.Bus', + kind: 'Data Interface', + isMatlabVariable: false, + }, + ], + }); + // The descriptor must not leak the full payloads to the webview. + expect(JSON.stringify(desc)).not.toContain('_array_class'); + }); + + it('clears', () => { + setDrag('a.sldd', 'design', 'Design Data', false, [item('X', 'Simulink.Bus', 'Simulink.Bus', 'Bus')]); + clearDrag(); + expect(getDrag()).toBeNull(); + expect(dragDescriptor()).toBeNull(); + }); +}); diff --git a/test/dropComplete.test.ts b/test/dropComplete.test.ts new file mode 100644 index 0000000..0d13409 --- /dev/null +++ b/test/dropComplete.test.ts @@ -0,0 +1,72 @@ +// Copyright 2026 The MathWorks, Inc. +// +// pasteEntries is the drop-completion transform: a drop is exactly a paste of +// the dragged payloads into the target section (a move additionally deletes the +// sources, handled by the host via the existing deleteEntry path). This covers +// the multi-item case a drop introduces — pasting several entries into one text +// in a single edit, each getting a unique name across the growing namespace. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { getModel, invalidate } from '../src/host/SlddModel.js'; +import { buildRows } from '../src/host/rowBuilder.js'; +import { findNode } from '../src/host/SlddModel.js'; +import { pasteEntries } from '../src/host/structuralEdit.js'; + +const archText = readFileSync(fileURLToPath(new URL('./fixtures/arch.sldd', import.meta.url)), 'utf8'); + +function model(uri: string) { + invalidate(uri); + return getModel(uri, 'arch.sldd', archText); +} +function payloadOf(uri: string, m: any, name: string) { + const id = buildRows(m).find((r: any) => r.Name?.label === name && !String(r.ID).startsWith('section:')).ID; + return findNode(uri, id).serialize() as Record; +} + +describe('pasteEntries — multi-item drop completion', () => { + it('pastes two entries into design, each uniquely named across the namespace', () => { + const uri = 'test://drop-multi.sldd'; + const m = model(uri); + const bus = payloadOf(uri, m, 'DataInterface'); + const nt = payloadOf(uri, m, 'NumericType'); + const design = m.children.find((s: any) => s.name === 'design'); + + const { newText, selectIds } = pasteEntries(archText, design, [bus, nt]); + expect(() => JSON.parse(newText)).not.toThrow(); + expect(selectIds).toHaveLength(2); + + invalidate(uri); + const m2 = getModel(uri, 'arch.sldd', newText); + const designNames = m2.children.find((s: any) => s.name === 'design').children.map((c: any) => c.name); + expect(designNames).toContain('DataInterface1'); + expect(designNames).toContain('NumericType1'); + // Distinct names — the second paste saw the first already in the namespace. + expect(new Set(designNames).size).toBe(designNames.length); + }); + + it('rejects the whole drop if any item is disallowed in the target', () => { + const uri = 'test://drop-reject.sldd'; + const m = model(uri); + const bus = payloadOf(uri, m, 'DataInterface'); + const svc = payloadOf(uri, m, 'ServiceInterface'); // Simulink.ServiceBus, design-illegal + const design = m.children.find((s: any) => s.name === 'design'); + + expect(() => pasteEntries(archText, design, [bus, svc])).toThrow(/not allowed|ServiceBus/i); + }); + + it('a single-item drop matches pasteEntry (one entry, one select id)', () => { + const uri = 'test://drop-single.sldd'; + const m = model(uri); + const bus = payloadOf(uri, m, 'DataInterface'); + const arch = m.children.find((s: any) => s.name === 'arch'); + + const { newText, selectIds } = pasteEntries(archText, arch, [bus]); + expect(selectIds).toHaveLength(1); + invalidate(uri); + const m2 = getModel(uri, 'arch.sldd', newText); + const copy = m2.children.find((s: any) => s.name === 'arch').children.find((c: any) => c.name === 'DataInterface1'); + expect(copy).toBeTruthy(); + expect((copy.metadata as any).isderived).toBe('1'); + }); +}); diff --git a/test/sectionRules.test.ts b/test/sectionRules.test.ts new file mode 100644 index 0000000..73fe500 --- /dev/null +++ b/test/sectionRules.test.ts @@ -0,0 +1,39 @@ +// Copyright 2026 The MathWorks, Inc. +// +// sectionRules extracts, from a live model, the per-section facts the webview +// needs to predict a drop with dropDecision (name, label, isDerived, allowed +// types). It is posted to each webview so dragover can run entirely client-side. +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { getModel, invalidate } from '../src/host/SlddModel.js'; +import { sectionRules } from '../src/host/sectionRules.js'; + +const archText = readFileSync(fileURLToPath(new URL('./fixtures/arch.sldd', import.meta.url)), 'utf8'); + +describe('sectionRules', () => { + it('returns one rule per section with its allow-list and derived flag', () => { + const uri = 'test://rules.sldd'; + invalidate(uri); + const m = getModel(uri, 'arch.sldd', archText); + const rules = sectionRules(m); + + const design = rules.find((r) => r.sectionName === 'design'); + const arch = rules.find((r) => r.sectionName === 'arch'); + expect(design).toBeTruthy(); + expect(arch).toBeTruthy(); + + // Derived flag distinguishes arch from design. + expect(design!.isDerived).toBe(false); + expect(arch!.isDerived).toBe(true); + + // Allow-lists mirror SectionNode.ALLOWED_TYPES. + expect(design!.allowedTypes).toContain('Simulink.Parameter'); + expect(arch!.allowedTypes).not.toContain('Simulink.Parameter'); + expect(arch!.allowedTypes).toContain('Simulink.ServiceBus'); + + // A human-readable label for the tooltip. + expect(design!.sectionLabel).toBe('Design Data'); + expect(arch!.sectionLabel).toBe('Architectural Data'); + }); +}); From 28222324f80557b84c5c700eae05b81309c8bfae Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Tue, 11 Aug 2026 19:21:54 -0400 Subject: [PATCH 04/14] Wire drag-and-drop end to end (host register + live webview feedback) Complete the host-mediated drag-and-drop: - SlddTextEditorProvider: dragStart snapshots the dragged entries into the drag register and broadcasts a payload-free descriptor to every webview; drop completes as copy/cut + paste (move deletes sources, same-doc inline and cross-doc via the source document's own edit); dragEnd/dispose clear the register. Section drop-rules ship with each setRows. - dex-tree-table: a dropPredictor hook drives the live cursor + a cursor-following tooltip ("Convert Bus to Data Interface" / "Simulink Parameter cannot be in Architectural Data"); rejected and no-op drops are refused; the drop carries only target+mode (the source comes from the host register, since dataTransfer doesn't cross the webview boundary); section headers aren't draggable. - table-main: backs the predictor with the pure dropDecision over the broadcast descriptor + shipped section rules, and relays the drag lifecycle to the host. Co-Authored-By: Claude Opus 4.8 --- src/dex/components/dex-tree-table.ts | 155 +++++++++++++++++++++----- src/host/SlddTextEditorProvider.ts | 157 +++++++++++++++++++++++++++ src/webview/table-main.ts | 88 +++++++++++++++ 3 files changed, 375 insertions(+), 25 deletions(-) diff --git a/src/dex/components/dex-tree-table.ts b/src/dex/components/dex-tree-table.ts index 0e047f4..37b18d5 100644 --- a/src/dex/components/dex-tree-table.ts +++ b/src/dex/components/dex-tree-table.ts @@ -376,6 +376,39 @@ export class DexTreeTable extends LitElement { border-radius: 2px; } + tr.data-row.drop-target-forbidden { + box-shadow: inset 0 0 0 2px var(--vscode-errorForeground, #f14c4c); + border-radius: 2px; + cursor: no-drop; + } + + /* Floating drag tooltip that trails the cursor; fixed so it can overhang + the scroll container. Non-interactive so it never eats drag events. */ + .drop-tooltip { + position: fixed; + z-index: 1000; + pointer-events: none; + padding: 3px 8px; + font-size: 12px; + font-family: var(--vscode-font-family, system-ui, sans-serif); + color: var(--vscode-editorHoverWidget-foreground, #fff); + background: var(--vscode-editorHoverWidget-background, #252526); + border: 1px solid var(--vscode-editorHoverWidget-border, #454545); + border-radius: 3px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + white-space: nowrap; + max-width: 360px; + } + + .drop-tooltip.forbidden { + color: var(--vscode-errorForeground, #f14c4c); + } + + .drop-tooltip-icon { + margin-right: 5px; + font-weight: bold; + } + td { padding: 3px 8px; border-right: 1px solid var(--dex-border-color-light, #e0e0e0); @@ -563,6 +596,25 @@ export class DexTreeTable extends LitElement { @state() private _dragSourceId: string | null = null; @state() private _dropTargetId: string | null = null; @state() private _dropPosition: 'above' | 'on' | null = null; + // Live drop feedback driven by the injected dropPredictor: whether the row + // under the cursor rejects the drop, plus the hover tooltip ("Convert Bus to + // Data Interface" / "Simulink Parameter cannot be in Architectural Data") and + // its screen position. Empty tooltip renders nothing. + @state() private _dropForbidden = false; + @state() private _dropTooltip = ''; + @state() private _dropTooltipX = 0; + @state() private _dropTooltipY = 0; + + // Predicts a drop for the row under the cursor, injected by the host-side glue + // (table-main) and backed by the pure dropDecision. Returns null when there is + // no active drag or the target can't be resolved, in which case the table + // falls back to its built-in permissive drag behavior. + @property({ attribute: false }) dropPredictor: + | (( + targetRowId: string, + mode: 'copy' | 'move', + ) => { canDrop: boolean; cursor: string; tooltip: string; noop: boolean } | null) + | null = null; @state() private _columnWidths: Map = new Map(); @state() private _columnOrder: string[] = [...DEFAULT_COLUMN_ORDER]; @@ -1426,27 +1478,29 @@ export class DexTreeTable extends LitElement { // --- Row Drag and Drop --- private _onRowDragStart(rowId: string, e: DragEvent): void { - this._dragSourceId = rowId; - const rowIds = - this.selectedRowIds.length > 1 && this.selectedRowIds.includes(rowId) ? this.selectedRowIds : [rowId]; - - const classNames: string[] = []; - for (const id of rowIds) { - const row = this.rows.find((r) => r.ID === id); - if (row) { - const dt = typeof row.DataType === 'object' ? row.DataType.text : String(row.DataType || ''); - if (dt) classNames.push(dt); - } + // Section headers aren't draggable entries. If the grabbed row is a header, + // there's nothing to drag; otherwise drag the multi-selection (when the + // grabbed row is part of it) or just the grabbed row, minus any headers. + if (rowId.indexOf('section:') === 0) { + e.preventDefault(); + return; } + this._dragSourceId = rowId; + const rowIds = ( + this.selectedRowIds.length > 1 && this.selectedRowIds.includes(rowId) ? this.selectedRowIds : [rowId] + ).filter((id) => id.indexOf('section:') !== 0); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'copyMove'; - e.dataTransfer.setData('application/dex-rows', JSON.stringify({ rowIds, classNames })); + // A payload is still set so the browser treats this as a real drag across + // the webview boundary, but the authoritative source lives in the host + // drag register (dataTransfer doesn't reliably survive the boundary). + e.dataTransfer.setData('application/dex-rows', JSON.stringify({ rowIds })); this._setDragImage(e, rowId, rowIds.length); } this.dispatchEvent( new CustomEvent('dex-row-drag-start', { - detail: { rowIds, classNames }, + detail: { rowIds }, bubbles: true, composed: true, }), @@ -1479,44 +1533,79 @@ export class DexTreeTable extends LitElement { this._dragSourceId = null; this._dropTargetId = null; this._dropPosition = null; + this._dropForbidden = false; + this._dropTooltip = ''; + // Tell the host the drag is over so it can clear the register + broadcast. + this.dispatchEvent(new CustomEvent('dex-row-drag-end', { bubbles: true, composed: true })); } private _onRowDragOver(rowId: string, e: DragEvent): void { if (rowId === this._dragSourceId) return; + const mode: 'copy' | 'move' = e.ctrlKey || e.metaKey ? 'copy' : 'move'; + + // Ask the injected predictor whether this drop is allowed and what to show. + // With no predictor (or no active drag it recognizes), fall back to the + // built-in permissive behavior so drag still works without the host glue. + const decision = this.dropPredictor ? this.dropPredictor(rowId, mode) : null; + if (decision) { + this._dropTargetId = rowId; + this._dropPosition = 'on'; + this._dropForbidden = !decision.canDrop; + this._dropTooltip = decision.tooltip; + this._dropTooltipX = e.clientX; + this._dropTooltipY = e.clientY; + if (decision.canDrop) { + // A droppable target must preventDefault so the browser fires `drop`. + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = decision.cursor === 'copy' ? 'copy' : 'move'; + } else if (e.dataTransfer) { + // Not droppable: leave the drop un-accepted (no preventDefault) and show + // the no-drop cursor. A no-op (same-section move) reads as "none" too. + e.dataTransfer.dropEffect = 'none'; + } + return; + } + e.preventDefault(); if (e.dataTransfer) { - e.dataTransfer.dropEffect = e.ctrlKey || e.metaKey ? 'copy' : 'move'; + e.dataTransfer.dropEffect = mode; } this._dropTargetId = rowId; this._dropPosition = 'on'; + this._dropForbidden = false; + this._dropTooltip = ''; } private _onRowDragLeave(_e: DragEvent): void { this._dropTargetId = null; this._dropPosition = null; + this._dropForbidden = false; + this._dropTooltip = ''; } private _onRowDrop(targetRowId: string, e: DragEvent): void { e.preventDefault(); e.stopPropagation(); - const data = e.dataTransfer?.getData('application/dex-rows'); - if (!data) return; + const mode: 'copy' | 'move' = e.ctrlKey || e.metaKey ? 'copy' : 'move'; - const { rowIds } = JSON.parse(data); - const isCopy = e.ctrlKey || e.metaKey; + // Respect the predictor: never complete a rejected or no-op drop. The drag + // may have started in ANOTHER webview, so the source rows come from the host + // drag register, not local dataTransfer (which doesn't cross the boundary) — + // the drop event only needs to carry the target + mode. + const decision = this.dropPredictor ? this.dropPredictor(targetRowId, mode) : null; this._dragSourceId = null; this._dropTargetId = null; this._dropPosition = null; + this._dropForbidden = false; + this._dropTooltip = ''; + + if (decision && !decision.canDrop) return; this.dispatchEvent( new CustomEvent('dex-row-drop', { - detail: { - sourceRowIds: rowIds, - targetRowId, - mode: isCopy ? 'copy' : 'move', - }, + detail: { targetRowId, mode }, bubbles: true, composed: true, }), @@ -2143,7 +2232,9 @@ export class DexTreeTable extends LitElement { 'copy' ? 'copied' : ''} ${isDragSource ? 'drag-source' : ''} ${isDropTarget && this._dropPosition === 'on' - ? 'drop-target-on' + ? this._dropForbidden + ? 'drop-target-forbidden' + : 'drop-target-on' : ''} ${isDropTarget && this._dropPosition === 'above' ? 'drop-target-above' : ''}" data-row-id="${row.ID}" draggable="true" @@ -2173,10 +2264,24 @@ export class DexTreeTable extends LitElement { - ${this._renderColumnMenu()} + ${this._renderColumnMenu()} ${this._renderDropTooltip()} `; } + // The floating drag tooltip that follows the cursor while dragging, describing + // what the drop will do ("Convert Bus to Data Interface") or why it can't + // ("Simulink Parameter cannot be in Architectural Data"). Rendered only while a + // predictor has produced a tooltip; the forbidden variant is styled distinctly. + private _renderDropTooltip() { + if (!this._dropTooltip) return html``; + return html`
+ ${this._dropForbidden ? html`` : ''}${this._dropTooltip} +
`; + } + private _renderColumnsButton() { return html`