Files
mdg-filius/packages/doc/src/commands.ts
T
julian.kiedaischandClaude Opus 5.5 6e97e3565d feat(editor): F4.5e a router's connections in the device sheet
SheetRun now clicks through Java's JConnectionsDialog with a router, a
computer and a switch cabled in the GUIContainer: a click marks a card,
the same click unmarks it, another card swaps the cables or moves the
one; "+" adds 192.168.0.10/24 up to eight cards, "-" removes the last
card with its cable down to none, and a router without a card keeps no
gateway.

The document adds, removes and swaps a router's cards (the same cables,
the same devices), Java's click logic is one function for the test and
the page, and the sheet names the address at a cable's far end. The
page "Verbindungen verwalten" shows Java's RJ45 buttons in Java's
colours with each card's far end below. Not adopted: the last card
stays. A card tab or a mark whose card is undone away falls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:12:27 +00:00

241 lines
11 KiB
TypeScript

// What the editor's design tools do to the document: a new device as FILIUS'
// palette makes it, a device moved or removed, a cable plugged or pulled —
// each a transaction with the person's origin, so that their undo takes back
// their own changes and nobody else's (plan §5.1; Scratch's lesson 2).
//
// A new device is what Java's constructor makes (GUIContainer.neuerKnoten,
// measured with tools/java-oracle's NewDeviceRun): a PC or notebook with
// 192.168.0.10/24 and an empty file system, a switch with 24 ports, a router
// with the interfaces chosen (2 to 8, all 192.168.0.10/24) and its firewall
// off, a home router with WAN 42.0.0.10/8 and LAN 192.168.0.10/24, a modem
// on port 12345. Java rolls the MAC addresses and a switch's SSID; here they
// come from the ids, as for every device without them (stable across saves).
//
// A cable follows Java's rules (GUIEvents, createConnection): a device's
// first free port, a home router's WAN port unless its LAN port is asked
// for (Java: the right half of its button), a wireless host only to a
// switch, two devices joined once.
import * as Y from 'yjs';
import {
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
defaultSwitchConfiguration,
firewallConfiguration,
interfaceKey,
VirtualFileSystem,
type Id,
type NodeKind,
type TopologyNode,
} from '@mdg-filius/core';
import { DEFAULT_MODEM_PORT, modemPair, type ImportedProject } from '@mdg-filius/fls';
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
/** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink'];
/**
* A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is
* closed); its connections dialog removes them down to `fewest` here (Java:
* none — see settings.ts).
*/
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2, fewest: 1 } as const;
export interface NewDevice {
readonly kind: Exclude<NodeKind, 'unsupported'>;
readonly id: Id;
/** Java's name of the kind in the editor's language (Knoten.TYPE: "Rechner", "Computer" …). */
readonly name: string;
/** The top left corner of its button. */
readonly x: number;
readonly y: number;
/** A router's interfaces. */
readonly interfaces?: number;
/** Makes the ports' ids. */
readonly newId: () => Id;
}
const HOST_INTERFACE = { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' } as const;
function portLabels(kind: NewDevice['kind'], interfaces: number): string[] {
switch (kind) {
case 'pc':
case 'notebook':
return ['eth0'];
case 'networkSwitch':
return Array.from({ length: 24 }, (_, index) => `sw${index + 1}`);
case 'router':
return Array.from({ length: interfaces }, (_, index) => `rt${index + 1}`);
case 'gateway':
return ['wan0', 'lan0'];
case 'remoteLink':
return ['remote0'];
}
}
/** The new device as a project of its own: Java's constructor's state in the editor's model. */
export function newDeviceProject(device: NewDevice): ImportedProject {
const interfaces = Math.min(ROUTER_INTERFACES.maximum, Math.max(ROUTER_INTERFACES.minimum, Math.trunc(device.interfaces ?? ROUTER_INTERFACES.standard)));
const node: TopologyNode = {
id: device.id,
kind: device.kind,
displayName: device.name,
hostLabelPolicy: 'manual',
position: { x: Math.round(device.x), y: Math.round(device.y) },
ports: portLabels(device.kind, interfaces).map((label) => ({ id: device.newId(), label, isOccupied: false })),
};
const project = emptyProject();
project.graph.nodes.push(node);
const card = (index: number, address: { ipAddress: string; subnetMask: string }) => project.interfaceConfigurations.set(interfaceKey(device.id, node.ports[index]!.id), address);
switch (device.kind) {
case 'pc':
case 'notebook':
project.deviceConfigurations.set(device.id, { ...HOST_INTERFACE, defaultGateway: '', dnsServer: '' });
project.virtualFileSystems.set(device.id, new VirtualFileSystem());
break;
case 'networkSwitch':
project.switchConfigurations.set(device.id, defaultSwitchConfiguration(device.id));
break;
case 'router':
node.ports.forEach((_, index) => card(index, HOST_INTERFACE));
project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
break;
case 'gateway':
card(0, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' });
card(1, HOST_INTERFACE);
project.deviceConfigurations.set(device.id, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0', defaultGateway: '', dnsServer: '' });
project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
break;
case 'remoteLink':
project.remoteLinkConfigurations.set(device.id, { pairIdentifier: modemPair(DEFAULT_MODEM_PORT), latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true });
break;
}
return project;
}
const nodes = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
/** Adds the device after all others. */
export function addDevice(doc: Y.Doc, device: NewDevice, origin: unknown): void {
const project = newDeviceProject(device);
doc.transact(() => {
const order = Math.max(-1, ...[...nodes(doc).values()].map((node) => node.get('order') as number)) + 1;
nodes(doc).set(device.id, nodeEntry(project.graph.nodes[0]!, order, project));
}, origin);
}
/** Moves a device's button to a place (whole pixels, as Java keeps them). */
export function moveDevice(doc: Y.Doc, id: Id, x: number, y: number, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
doc.transact(() => {
node.set('x', Math.round(x));
node.set('y', Math.round(y));
}, origin);
}
/** Removes a device with its cables, as Java does. */
export function removeDevice(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => {
for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId);
nodes(doc).delete(id);
}, origin);
}
export function removeLink(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => links(doc).delete(id), origin);
}
/** Java's "remove all cables" of a device (its context menu). */
export function removeLinksOf(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => {
for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId);
}, origin);
}
/** A cable's end: a device, and the port when one was asked for. */
export interface CableEnd {
readonly node: Id;
readonly port?: Id;
}
/**
* Why a cable cannot be plugged, with Java's message where it has one:
* noFreePort guievents_msg1, wanOccupied guievents_msg22, lanOccupied
* guievents_msg23, wirelessNotPossible guievents_msg26, alreadyConnected
* guievents_msg12.
*/
export type ConnectError = 'unknownDevice' | 'sameDevice' | 'unknownPort' | 'portTaken' | 'noFreePort' | 'wanOccupied' | 'lanOccupied' | 'wirelessNotPossible' | 'alreadyConnected';
/** The ports cables use, as `node/port`. */
function pluggedPorts(doc: Y.Doc): Set<string> {
return new Set([...links(doc).values()].flatMap((link) => [`${link.get('sourceNode')}/${link.get('sourcePort')}`, `${link.get('targetNode')}/${link.get('targetPort')}`]));
}
/** The port a cable takes at an end, or why there is none. */
export function cablePort(doc: Y.Doc, end: CableEnd): { readonly port: Id } | { readonly error: ConnectError } {
const node = nodes(doc).get(end.node);
if (node === undefined) return { error: 'unknownDevice' };
const ports = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().map((port) => ({ id: port.get('id') as Id, label: port.get('label') as string }));
const plugged = pluggedPorts(doc);
const free = (port: Id) => !plugged.has(`${end.node}/${port}`);
const kind = node.get('kind') as NodeKind;
if (kind === 'gateway') {
// WAN unless LAN is asked for; each its own message when taken.
const wanted = end.port === undefined ? ports[0] : ports.find((port) => port.id === end.port);
if (wanted === undefined) return { error: 'unknownPort' };
if (free(wanted.id)) return { port: wanted.id };
return { error: wanted.label === 'wan0' ? 'wanOccupied' : wanted.label === 'lan0' ? 'lanOccupied' : 'portTaken' };
}
if (end.port !== undefined) {
if (!ports.some((port) => port.id === end.port)) return { error: 'unknownPort' };
return free(end.port) ? { port: end.port } : { error: 'portTaken' };
}
const first = ports.find((port) => free(port.id));
return first === undefined ? { error: 'noFreePort' } : { port: first.id };
}
/** A host whose card joins a switch's wireless network. */
function wireless(doc: Y.Doc, id: Id): boolean {
const node = nodes(doc).get(id)!;
const kind = node.get('kind') as NodeKind;
const settings = node.get('settings') as Y.Map<Y.Map<unknown>>;
return (kind === 'pc' || kind === 'notebook') && settings.get('wireless')?.get('isEnabled') === true;
}
/** Plugs a cable between two devices, from the first to the second, or says why it cannot. */
export function connect(doc: Y.Doc, from: CableEnd, to: CableEnd, linkId: Id, origin: unknown): { readonly link: Id } | { readonly error: ConnectError } {
if (!nodes(doc).has(from.node) || !nodes(doc).has(to.node)) return { error: 'unknownDevice' };
if (from.node === to.node) return { error: 'sameDevice' };
const source = cablePort(doc, from);
if ('error' in source) return source;
const target = cablePort(doc, to);
if ('error' in target) return target;
const kindOf = (id: Id) => nodes(doc).get(id)!.get('kind') as NodeKind;
if ((wireless(doc, from.node) && kindOf(to.node) !== 'networkSwitch') || (wireless(doc, to.node) && kindOf(from.node) !== 'networkSwitch')) return { error: 'wirelessNotPossible' };
const joined = [...links(doc).values()].some((link) => {
const ends = [link.get('sourceNode'), link.get('targetNode')];
return ends.includes(from.node) && ends.includes(to.node);
});
if (joined) return { error: 'alreadyConnected' };
doc.transact(() => {
const link = new Y.Map<unknown>();
link.set('order', Math.max(-1, ...inOrder(links(doc)).map(([, each]) => each.get('order') as number)) + 1);
link.set('sourceNode', from.node);
link.set('sourcePort', source.port);
link.set('targetNode', to.node);
link.set('targetPort', target.port);
links(doc).set(linkId, link);
}, origin);
return { link: linkId };
}
/**
* A person's undo: their own changes to the devices, cables and
* documentation items, nobody else's. Changes within a short while (a drag)
* are taken back at once.
*/
export function createUndoManager(doc: Y.Doc, origin: unknown): Y.UndoManager {
return new Y.UndoManager([nodes(doc), links(doc), doc.getMap(ROOTS.docs)], { trackedOrigins: new Set([origin]), captureTimeout: 500 });
}