Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ platform API version.

### Fixed

- **The palette creates nodes, and the property panel adds entity ports, in
every product's diagram.** Both handlers accepted only a `.py` source, so a
Verilog, VHDL or NDF diagram answered a palette drop with nothing. Like every
other handler that writes to the source, they now ask only that the model
has one.
- **Copy then paste re-creates the copied elements.** Copy wrote the entity's
name as `entityName` with its source text, while paste looked for `name` and
`typeName`, so a copied node pasted as nothing; cut and duplicate already
wrote the paste shape. One payload now lives in `@dialogram/shared`
(`DiagramClipboardPayload`, `parseClipboardPayload`,
`clipboardDataFromPayload`), written by copy, cut and duplicate and read by
paste and the webview's post-paste selection. The server's
`clipboard/py-clipboard` module is gone; its builder is
`buildClipboardPayload` in `clipboard/clipboard`.
- **A pasted or duplicated connection joins the pasted copies**, not the
entities it was copied from, which used to get a second wire while the copy
stayed unwired. The pasted name follows one rule, `nextFreeName` in
`@dialogram/shared` (`add`, then `add2`, `add3`), on the sidecar side and in
the webview, which predicted `add1` and so never selected the pasted node.
A paste with nothing to paste says so instead of doing nothing.
- A layout change still on its 500 ms debounce timer when the window closes
is written out: the host flushes every diagram's pending layout saves on
deactivation.
Expand Down
50 changes: 0 additions & 50 deletions packages/diagram-client/src/clipboard-payload.ts

This file was deleted.

18 changes: 5 additions & 13 deletions packages/diagram-client/src/post-edit-selection-service.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import { injectable } from 'inversify';
import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared';
import { tryParseWorkflowClipboardPayload } from './clipboard-payload';
import { nextFreeName, parseClipboardPayload, WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared';

export type PendingSelection = {
entityNames: string[];
boundaryPortNames: string[];
};

/** The name the pasted element will get, by the rule the edit backend applies. */
function generateUnique(base: string, used: Set<string>): string {
if (!used.has(base)) {
used.add(base);
return base;
}
let i = 1;
while (used.has(`${base}${i}`)) {
i++;
}
const name = `${base}${i}`;
const name = nextFreeName(base, used);
used.add(name);
return name;
}
Expand Down Expand Up @@ -103,7 +95,7 @@ export class PostEditSelectionService {
// Called before dispatching PasteOperation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prepareForPaste(clipboardData: Record<string, string>, modelRoot: any): void {
const payload = tryParseWorkflowClipboardPayload(clipboardData);
const payload = parseClipboardPayload(clipboardData);
if (!payload) {
this.pending = undefined;
return;
Expand All @@ -116,7 +108,7 @@ export class PostEditSelectionService {

for (const item of payload.items ?? []) {
if (item.kind === 'entity') {
const newName = generateUnique(item.entityName, usedEntities);
const newName = generateUnique(item.name, usedEntities);
entityNames.push(newName);
}
if (item.kind === 'boundaryPort') {
Expand Down
42 changes: 42 additions & 0 deletions packages/diagram-client/test/post-edit-selection-paste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* After a paste the webview selects the pasted elements by the names the
* sidecar gives them. It reads those names from the clipboard payload copy
* wrote, so it must read the shape copy writes.
*/
import 'reflect-metadata';
import { describe, expect, it } from 'vitest';
import { clipboardDataFromPayload, WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared';
import { PostEditSelectionService } from '../src/post-edit-selection-service';

function modelRootWith(elements: Array<{ id: string; type: string; args: Record<string, unknown> }>) {
return { type: 'graph', children: elements };
}

describe('PostEditSelectionService.prepareForPaste', () => {
it('plans the pasted names from the entity names of the copied payload', () => {
const service = new PostEditSelectionService();
const root = modelRootWith([
{ id: 'add', type: WorkflowDiagramTypes.NODE_ACTOR, args: { [WorkflowDiagramMetadata.ENTITY_NAME]: 'add' } },
{ id: 'a', type: WorkflowDiagramTypes.NODE_BOUNDARY_INPUT, args: { [WorkflowDiagramMetadata.PORT_NAME]: 'a' } }
]);
const clipboardData = clipboardDataFromPayload({
version: 1,
sourceUri: 'file:///hdl/top.sv',
items: [
{ kind: 'entity', elementTypeId: WorkflowDiagramTypes.NODE_ACTOR, name: 'add', typeName: 'Adder' },
{ kind: 'boundaryPort', elementTypeId: WorkflowDiagramTypes.NODE_BOUNDARY_INPUT, direction: 'input', portName: 'a' }
]
});

service.prepareForPaste(clipboardData, root);

// The same names the toolkit's paste writes (see `nextFreeName`).
expect((service as any).pending).toEqual({ entityNames: ['add2'], boundaryPortNames: ['a2'] });
});

it('plans nothing for clipboard data that is not a diagram payload', () => {
const service = new PostEditSelectionService();
service.prepareForPaste({ 'text/plain': 'not a payload' }, modelRootWith([]));
expect((service as any).pending).toBeUndefined();
});
});
156 changes: 27 additions & 129 deletions packages/diagram-server/src/clipboard/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,8 @@
import type { Args } from '@eclipse-glsp/protocol';
import type { ModelState } from '@eclipse-glsp/server';
import * as vscode from 'vscode';
import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared';
import { WorkflowDiagramMetadata, WorkflowDiagramTypes, type DiagramClipboardItem, type DiagramClipboardPayload } from '@dialogram/shared';

export const WORKFLOW_CLIPBOARD_FORMAT = 'application/json';

type SerializedPosition = { line: number; character: number };
export type SerializedRange = { start: SerializedPosition; end: SerializedPosition };

export type WorkflowClipboardItem =
| {
kind: 'entity';
elementTypeId: string;
entityName: string;
sourceRange: SerializedRange;
text: string;
}
| {
kind: 'boundaryPort';
elementTypeId: string;
direction: 'input' | 'output';
portName: string;
sourceRange: SerializedRange;
text: string;
}
| {
kind: 'connection';
elementTypeId: string;
sourceRange: SerializedRange;
text: string;
};

export interface WorkflowClipboardPayload {
version: 1;
sourceUri: string;
items: WorkflowClipboardItem[];
}

function isSerializedPosition(value: unknown): value is SerializedPosition {
if (!value || typeof value !== 'object') {
return false;
}
const v = value as Record<string, unknown>;
return typeof v.line === 'number' && typeof v.character === 'number';
}

export function isSerializedRange(value: unknown): value is SerializedRange {
if (!value || typeof value !== 'object') {
return false;
}
const v = value as Record<string, unknown>;
return isSerializedPosition(v.start) && isSerializedPosition(v.end);
}

function findNearestSourceRange(element: unknown): SerializedRange | undefined {
let current: any = element;
while (current) {
const args: Args | undefined = current.args;
const range = args?.[WorkflowDiagramMetadata.SOURCE_RANGE];
if (isSerializedRange(range)) {
return range;
}
current = current.parent;
}
return undefined;
}

function getTextForRange(document: vscode.TextDocument, range: SerializedRange): string {
return document.getText(
new vscode.Range(
range.start.line,
range.start.character,
range.end.line,
range.end.character
)
);
}
export { clipboardDataFromPayload, parseClipboardPayload, type DiagramClipboardItem, type DiagramClipboardPayload } from '@dialogram/shared';

function isReadOnly(element: any): boolean {
const args: Args | undefined = element?.args;
Expand All @@ -85,89 +12,60 @@ function isReadOnly(element: any): boolean {
return args[WorkflowDiagramMetadata.IS_READ_ONLY] === true || args[WorkflowDiagramMetadata.IS_VIRTUAL] === true;
}

export async function buildWorkflowClipboardPayload(
modelState: ModelState,
sourceUri: string,
selectedElementIds: string[]
): Promise<WorkflowClipboardPayload> {
const doc = await vscode.workspace.openTextDocument(vscode.Uri.parse(sourceUri));

const items: WorkflowClipboardItem[] = [];
/**
* The clipboard payload for the selected elements: every editable entity,
* boundary port and connection among them, by the names a paste re-creates
* it from. Read-only and virtual elements are left out.
*/
export function buildClipboardPayload(modelState: ModelState, sourceUri: string, selectedElementIds: string[]): DiagramClipboardPayload {
const items: DiagramClipboardItem[] = [];
for (const id of selectedElementIds) {
const element: any = modelState.index.get(id);
if (!element || isReadOnly(element)) {
continue;
}

const elementTypeId: string | undefined = element.type;
const args: Args | undefined = element.args;

const range = findNearestSourceRange(element);
if (!range) {
continue;
}

const text = getTextForRange(doc, range);
if (!text || text.trim() === '') {
continue;
}

if (
elementTypeId === WorkflowDiagramTypes.NODE_ACTOR ||
elementTypeId === WorkflowDiagramTypes.NODE_NETWORK ||
elementTypeId === WorkflowDiagramTypes.NODE_EXTERNAL_ACTOR
) {
const entityName = args?.[WorkflowDiagramMetadata.ENTITY_NAME];
if (typeof entityName !== 'string' || entityName.trim() === '') {
continue;
const name = args?.[WorkflowDiagramMetadata.ENTITY_NAME];
const typeName = args?.[WorkflowDiagramMetadata.ENTITY_TYPE];
if (typeof name === 'string' && name.trim() !== '' && typeof typeName === 'string' && typeName.trim() !== '') {
items.push({ kind: 'entity', elementTypeId, name, typeName });
}
items.push({
kind: 'entity',
elementTypeId,
entityName,
sourceRange: range,
text
});
continue;
}

if (elementTypeId === WorkflowDiagramTypes.NODE_BOUNDARY_INPUT || elementTypeId === WorkflowDiagramTypes.NODE_BOUNDARY_OUTPUT) {
const portName = args?.[WorkflowDiagramMetadata.PORT_NAME];
if (typeof portName !== 'string' || portName.trim() === '') {
continue;
const portType = args?.[WorkflowDiagramMetadata.PORT_TYPE];
if (typeof portName === 'string' && portName.trim() !== '') {
items.push({
kind: 'boundaryPort',
elementTypeId,
direction: elementTypeId === WorkflowDiagramTypes.NODE_BOUNDARY_INPUT ? 'input' : 'output',
portName,
portType: typeof portType === 'string' ? portType : undefined
});
}
items.push({
kind: 'boundaryPort',
elementTypeId,
direction: elementTypeId === WorkflowDiagramTypes.NODE_BOUNDARY_INPUT ? 'input' : 'output',
portName,
sourceRange: range,
text
});
continue;
}

if (elementTypeId === WorkflowDiagramTypes.EDGE_CONNECTION || elementTypeId === WorkflowDiagramTypes.EDGE_CONNECTION_NO_ARROW) {
items.push({
kind: 'connection',
elementTypeId,
sourceRange: range,
text
from: args?.['wf:from'] as string | undefined,
to: args?.['wf:to'] as string | undefined,
outPort: args?.['wf:outPort'] as string | undefined,
inPort: args?.['wf:inPort'] as string | undefined
});
continue;
}
}

return {
version: 1,
sourceUri,
items
};
}

export function clipboardDataFromPayload(payload: WorkflowClipboardPayload): Record<string, string> {
return {
[WORKFLOW_CLIPBOARD_FORMAT]: JSON.stringify(payload)
};
return { version: 1, sourceUri, items };
}

Loading
Loading