Skip to content
Merged
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
129 changes: 129 additions & 0 deletions apps/web/src/app/board-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import { computeElementDiff } from './board-utils';

describe('computeElementDiff', () => {
it('returns empty ops for identical arrays', () => {
const arr = [{ id: 'a', text: 'hello' }];
const result = computeElementDiff(arr, [...arr]);
expect(result.removes).toEqual([]);
expect(result.sets).toEqual([]);
expect(result.inserts).toEqual([]);
});

it('returns empty ops for empty arrays', () => {
const result = computeElementDiff([], []);
expect(result.removes).toEqual([]);
expect(result.sets).toEqual([]);
expect(result.inserts).toEqual([]);
});

it('detects element removal with back-to-front indices', () => {
const current = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const next = [{ id: 'b' }];
const result = computeElementDiff(current, next);
expect(result.removes).toEqual([2, 0]);
expect(result.sets).toEqual([]);
expect(result.inserts).toEqual([]);
});

it('detects property changes', () => {
const current = [{ id: 'a', text: 'old', color: 'red' }];
const next = [{ id: 'a', text: 'new', color: 'red' }];
const result = computeElementDiff(current, next);
expect(result.removes).toEqual([]);
expect(result.sets).toEqual([
{
index: 0,
properties: { text: 'old' },
newProperties: { text: 'new' },
},
]);
expect(result.inserts).toEqual([]);
});

it('detects property deletion as null in newProperties', () => {
const current = [{ id: 'a', text: 'hello', extra: 'remove-me' }];
const next = [{ id: 'a', text: 'hello' }];
const result = computeElementDiff(current, next);
expect(result.sets).toHaveLength(1);
expect(result.sets[0].newProperties.extra).toBeNull();
});

it('detects property addition', () => {
const current = [{ id: 'a', text: 'hello' }];
const next = [{ id: 'a', text: 'hello', color: 'blue' }];
const result = computeElementDiff(current, next);
expect(result.sets).toHaveLength(1);
expect(result.sets[0].newProperties).toEqual({ color: 'blue' });
expect(result.sets[0].properties).toEqual({});
});

it('detects element insertion', () => {
const current = [{ id: 'a' }];
const next = [{ id: 'a' }, { id: 'b', text: 'new' }];
const result = computeElementDiff(current, next);
expect(result.removes).toEqual([]);
expect(result.sets).toEqual([]);
expect(result.inserts).toEqual([
{ index: 1, element: { id: 'b', text: 'new' } },
]);
});

it('handles all inserts from empty', () => {
const next = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const result = computeElementDiff([], next);
expect(result.removes).toEqual([]);
expect(result.sets).toEqual([]);
expect(result.inserts).toHaveLength(3);
expect(result.inserts.map((i) => i.element.id)).toEqual(['a', 'b', 'c']);
});

it('handles all removes to empty', () => {
const current = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const result = computeElementDiff(current, []);
expect(result.removes).toEqual([2, 1, 0]);
expect(result.sets).toEqual([]);
expect(result.inserts).toEqual([]);
});

it('handles complex mixed changes (remove + modify + insert)', () => {
const current = [
{ id: 'a', text: 'A' },
{ id: 'b', text: 'B' },
{ id: 'c', text: 'C' },
];
const next = [
{ id: 'a', text: 'A-modified' },
{ id: 'd', text: 'D' },
{ id: 'c', text: 'C' },
];
const result = computeElementDiff(current, next);
// b removed (index 1 in original)
expect(result.removes).toEqual([1]);
// a modified (index 0 in post-remove [a, c])
expect(result.sets).toHaveLength(1);
expect(result.sets[0].index).toBe(0);
expect(result.sets[0].newProperties.text).toBe('A-modified');
// d inserted at index 1
expect(result.inserts).toEqual([
{ index: 1, element: { id: 'd', text: 'D' } },
]);
});

it('handles deep object property changes', () => {
const current = [{ id: 'a', children: [{ text: 'old' }] }];
const next = [{ id: 'a', children: [{ text: 'new' }] }];
const result = computeElementDiff(current, next);
expect(result.sets).toHaveLength(1);
expect(result.sets[0].newProperties.children).toEqual([{ text: 'new' }]);
});

it('preserves insert order for multiple new elements', () => {
const current = [{ id: 'a' }];
const next = [{ id: 'x' }, { id: 'a' }, { id: 'y' }];
const result = computeElementDiff(current, next);
expect(result.inserts).toHaveLength(2);
expect(result.inserts[0]).toEqual({ index: 0, element: { id: 'x' } });
expect(result.inserts[1]).toEqual({ index: 2, element: { id: 'y' } });
});
});
72 changes: 72 additions & 0 deletions apps/web/src/app/board-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export interface DiffOps {
removes: number[];
sets: {
index: number;
properties: Record<string, unknown>;
newProperties: Record<string, unknown>;
}[];
inserts: {
index: number;
element: { id: string; [key: string]: unknown };
}[];
}

type Element = { id: string; [key: string]: unknown };

/**
* Compute the diff operations needed to transform `current` into `next`.
* Returns removes (back-to-front indices), sets (property changes), and inserts (new elements).
*/
export function computeElementDiff(
current: Element[],
next: Element[]
): DiffOps {
const newById = new Map(next.map((el) => [el.id, el]));
const newIds = new Set(next.map((el) => el.id));

// Phase 1: removes (back-to-front)
const removes: number[] = [];
for (let i = current.length - 1; i >= 0; i--) {
if (!newIds.has(current[i].id)) {
removes.push(i);
}
}

// Simulate removal to get post-remove state
const afterRemove = current.filter((el) => newIds.has(el.id));

// Phase 2: sets (property changes)
const sets: DiffOps['sets'] = [];
for (let i = 0; i < afterRemove.length; i++) {
const cur = afterRemove[i];
const target = newById.get(cur.id);
if (!target) continue;
const properties: Record<string, unknown> = {};
const newProperties: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(cur), ...Object.keys(target)]);
for (const key of allKeys) {
if (key === 'id') continue;
const curVal = cur[key];
const newVal = target[key];
if (JSON.stringify(curVal) !== JSON.stringify(newVal)) {
if (curVal !== undefined) properties[key] = curVal;
newProperties[key] = newVal !== undefined ? newVal : null;
}
}
if (Object.keys(newProperties).length > 0) {
sets.push({ index: i, properties, newProperties });
}
}

// Phase 3: inserts
const currentIds = new Set(afterRemove.map((el) => el.id));
const inserts: DiffOps['inserts'] = [];
for (let i = 0; i < next.length; i++) {
if (!currentIds.has(next[i].id)) {
inserts.push({ index: i, element: next[i] });
currentIds.add(next[i].id);
}
}

return { removes, sets, inserts };
}
55 changes: 16 additions & 39 deletions apps/web/src/app/file-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@plait/core';
import type { VyncCanvasFile, VyncViewport, WsMessage } from '@vync/shared';
import localforage from 'localforage';
import { computeElementDiff } from './board-utils';

function toPlaitViewport(v: VyncViewport): Viewport {
return { zoom: v.zoom, origination: [v.x, v.y] } as Viewport;
Expand Down Expand Up @@ -53,55 +54,31 @@ function applyExternalChanges(
board: PlaitBoard,
newChildren: PlaitElement[]
): void {
const newById = new Map(newChildren.map((el) => [el.id, el]));
const newIds = new Set(newChildren.map((el) => el.id));
const diff = computeElementDiff(
board.children as { id: string; [key: string]: unknown }[],
newChildren as { id: string; [key: string]: unknown }[]
);

// Phase 1: Remove deleted nodes (back-to-front to preserve indices)
for (let i = board.children.length - 1; i >= 0; i--) {
if (!newIds.has(board.children[i].id)) {
Transforms.removeNode(board, [i]);
}
for (const idx of diff.removes) {
Transforms.removeNode(board, [idx]);
}

// Phase 2: Update modified nodes via board.apply() directly.
// Transforms.setNode strips null from newProperties, so property deletions
// would be silently lost. Using board.apply() preserves null → delete semantics.
for (let i = 0; i < board.children.length; i++) {
const current = board.children[i];
const target = newById.get(current.id);
if (!target) continue;
const properties: Record<string, unknown> = {};
const newProperties: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(current), ...Object.keys(target)]);
for (const key of allKeys) {
if (key === 'id') continue;
const curVal = (current as Record<string, unknown>)[key];
const newVal = (target as Record<string, unknown>)[key];
if (JSON.stringify(curVal) !== JSON.stringify(newVal)) {
if (curVal !== undefined) properties[key] = curVal;
newProperties[key] = newVal !== undefined ? newVal : null;
}
}
if (Object.keys(newProperties).length > 0) {
board.apply({
type: 'set_node',
path: [i],
properties: properties as Partial<PlaitNode>,
newProperties: newProperties as Partial<PlaitNode>,
});
}
for (const set of diff.sets) {
board.apply({
type: 'set_node',
path: [set.index],
properties: set.properties as Partial<PlaitNode>,
newProperties: set.newProperties as Partial<PlaitNode>,
});
}

// Phase 3: Insert new nodes so final order matches newChildren exactly.
// Walk newChildren in order; for each new ID, insert at position i.
// board.children grows with each insert, but index i stays correct because
// we process newChildren sequentially and all prior positions are settled.
const currentIds = new Set(board.children.map((el) => el.id));
for (let i = 0; i < newChildren.length; i++) {
if (!currentIds.has(newChildren[i].id)) {
Transforms.insertNode(board, newChildren[i], [i]);
currentIds.add(newChildren[i].id);
}
for (const ins of diff.inserts) {
Transforms.insertNode(board, ins.element as PlaitElement, [ins.index]);
}
}

Expand Down
113 changes: 113 additions & 0 deletions apps/web/src/app/tab-bar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { TabBar } from './tab-bar';
import type { TabInfo } from './tab-utils';

afterEach(cleanup);

function renderTabBar(overrides: Partial<Parameters<typeof TabBar>[0]> = {}) {
const props = {
tabs: [] as TabInfo[],
activeFilePath: null as string | null,
registeredFiles: [] as string[],
discoveredFiles: [] as string[],
onTabClick: vi.fn(),
onTabClose: vi.fn(),
onAddFile: vi.fn(),
onDiscoverFile: vi.fn(),
onDropdownOpen: vi.fn(),
...overrides,
};
return { ...render(<TabBar {...props} />), props };
}

describe('TabBar', () => {
it('renders tabs from props', () => {
const tabs: TabInfo[] = [
{ filePath: '/a/plan.vync', label: 'plan.vync' },
{ filePath: '/b/notes.vync', label: 'notes.vync' },
];
renderTabBar({ tabs });
expect(screen.getByText('plan.vync')).toBeTruthy();
expect(screen.getByText('notes.vync')).toBeTruthy();
});

it('marks active tab with active class', () => {
const tabs: TabInfo[] = [
{ filePath: '/a/plan.vync', label: 'plan.vync' },
{ filePath: '/b/notes.vync', label: 'notes.vync' },
];
renderTabBar({ tabs, activeFilePath: '/b/notes.vync' });
const activeTab = screen.getByText('notes.vync').closest('.vync-tab');
expect(activeTab?.classList.contains('vync-tab--active')).toBe(true);
const inactiveTab = screen.getByText('plan.vync').closest('.vync-tab');
expect(inactiveTab?.classList.contains('vync-tab--active')).toBe(false);
});

it('calls onTabClick when tab is clicked', () => {
const onTabClick = vi.fn();
const tabs: TabInfo[] = [{ filePath: '/a/plan.vync', label: 'plan.vync' }];
renderTabBar({ tabs, onTabClick });
fireEvent.click(screen.getByText('plan.vync'));
expect(onTabClick).toHaveBeenCalledWith('/a/plan.vync');
});

it('calls onTabClose and stops propagation on close button click', () => {
const onTabClick = vi.fn();
const onTabClose = vi.fn();
const tabs: TabInfo[] = [{ filePath: '/a/plan.vync', label: 'plan.vync' }];
renderTabBar({ tabs, onTabClick, onTabClose });
fireEvent.click(screen.getByLabelText('Close plan.vync'));
expect(onTabClose).toHaveBeenCalledWith('/a/plan.vync');
expect(onTabClick).not.toHaveBeenCalled();
});

it('toggles dropdown on + button click', () => {
const onDropdownOpen = vi.fn();
renderTabBar({
registeredFiles: ['/a/plan.vync'],
discoveredFiles: [],
onDropdownOpen,
});
const addBtn = screen.getByLabelText('Open file');
expect(addBtn.getAttribute('aria-expanded')).toBe('false');

fireEvent.click(addBtn);
expect(addBtn.getAttribute('aria-expanded')).toBe('true');
expect(onDropdownOpen).toHaveBeenCalled();

fireEvent.click(addBtn);
expect(addBtn.getAttribute('aria-expanded')).toBe('false');
});

it('shows reopenable files in dropdown', () => {
const tabs: TabInfo[] = [{ filePath: '/a/plan.vync', label: 'plan.vync' }];
renderTabBar({
tabs,
registeredFiles: ['/a/plan.vync', '/b/notes.vync'],
discoveredFiles: [],
});
fireEvent.click(screen.getByLabelText('Open file'));
expect(screen.getByText('Reopen')).toBeTruthy();
expect(screen.getByText('notes.vync')).toBeTruthy();
});

it('shows discovered files in dropdown', () => {
renderTabBar({
discoveredFiles: ['/c/ideas.vync'],
});
fireEvent.click(screen.getByLabelText('Open file'));
expect(screen.getByText('Open')).toBeTruthy();
expect(screen.getByText('ideas.vync')).toBeTruthy();
});

it('shows empty state when no files available', () => {
renderTabBar({
registeredFiles: [],
discoveredFiles: [],
});
fireEvent.click(screen.getByLabelText('Open file'));
expect(screen.getByText(/No files found/)).toBeTruthy();
});
});
Loading
Loading