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
7 changes: 6 additions & 1 deletion apps/e2e/src/pages/LokeeHistoryPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export class LokeeHistoryPage {
constructor(private page: Page) {}

async openHistoryPane(): Promise<void> {
await clickWhen(this.page, '[data-testid="view-sync-btn"]');
const historyView = this.page.locator('[data-testid="lokee-weave-view"]');
if (await historyView.isVisible().catch(() => false)) return;
const syncBtn = this.page.locator('[data-testid="view-sync-btn"]');
if (await syncBtn.isVisible().catch(() => false)) {
await clickWhen(this.page, '[data-testid="view-sync-btn"]');
}
await clickWhen(this.page, '[data-testid="sync-pane-history-btn"]');
await waitFor(this.page, '[data-testid="lokee-weave-view"]', 20_000);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ describe('App boot', () => {
await driver.locator('[data-testid="sync-pane-history-btn"]').click();
await driver.waitForSelector('[data-testid="lokee-weave-view"]', { timeout: 15_000 });
expect(await driver.locator('[data-testid="lokee-weave-view"]').isVisible()).toBe(true);
expect(await driver.locator('[data-testid="workspace-switcher"]').count()).toBe(0);
expect(await driver.locator('[data-testid="lokee-history-compare-bar"]').isVisible()).toBe(true);
await driver.locator('[data-testid="sync-pane-compare-btn"]').click();
expect(await driver.locator('[data-testid="workspace-switcher"]').isVisible()).toBe(true);
});
});
93 changes: 49 additions & 44 deletions apps/web/src/frontend/components/TopToolbar.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { describe, expect, it, beforeEach } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { HistoryCompareBar } from './HistoryCompareBar';
import { useLokeeHistoryStore } from '../../store/lokeeHistoryStore';

const DB = {
id: 'db1',
dialect: 'postgres',
host: 'localhost',
database: 'foxdb',
schema: 'public',
versionCount: 2,
lastSeenAt: '2026-08-11T00:00:00.000Z',
};

beforeEach(() => {
useLokeeHistoryStore.setState({
databaseId: 'db1',
originalVersionId: null,
targetVersionId: null,
databases: [DB],
versions: [
{ id: 'v2', number: 2, name: 'Head' },
{ id: 'v1', number: 1, name: 'Initial' },
],
});
});

describe('HistoryCompareBar', () => {
it('defaults Original to the previous version and Target to the current database', () => {
render(<HistoryCompareBar />);
expect((screen.getByTestId('lokee-original-version') as HTMLSelectElement).value).toBe('v1');
expect((screen.getByTestId('lokee-target-version') as HTMLSelectElement).value).toBe('');
expect(screen.getByTestId('lokee-target-version').textContent).toContain('Current database');
expect(screen.getByTestId('lokee-database-select').textContent).toContain('POSTGRES');
});

it('lets the user pick an older Target version', () => {
render(<HistoryCompareBar />);
fireEvent.change(screen.getByTestId('lokee-target-version'), { target: { value: 'v1' } });
expect(useLokeeHistoryStore.getState().targetVersionId).toBe('v1');
expect((screen.getByTestId('lokee-target-version') as HTMLSelectElement).value).toBe('v1');
});

it('swaps Original and Target like Compare', () => {
render(<HistoryCompareBar />);
fireEvent.click(screen.getByTestId('lokee-history-swap-btn'));
expect(useLokeeHistoryStore.getState().originalVersionId).toBe('v2');
expect(useLokeeHistoryStore.getState().targetVersionId).toBe('v1');
});
});
131 changes: 131 additions & 0 deletions apps/web/src/frontend/components/lokee-weave/HistoryCompareBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* History toolbar — same Original → Target cards as Compare, but the sides
* are versions of one captured database instead of two live connections.
*/
import React, { useMemo } from 'react';
import { ArrowLeftRight, ArrowRight } from 'lucide-react';
import { useLokeeHistoryStore } from '../../store/lokeeHistoryStore';
import {
historyVersionLabel,
lokeeDatabaseLabel,
resolveHistoryCompare,
sortVersionsNewestFirst,
} from '../../lib/historyCompare';

export function HistoryCompareBar(): React.ReactElement {
const databases = useLokeeHistoryStore((s) => s.databases);
const versions = useLokeeHistoryStore((s) => s.versions);
const databaseId = useLokeeHistoryStore((s) => s.databaseId);
const originalVersionId = useLokeeHistoryStore((s) => s.originalVersionId);
const targetVersionId = useLokeeHistoryStore((s) => s.targetVersionId);
const setDatabaseId = useLokeeHistoryStore((s) => s.setDatabaseId);
const setOriginalVersionId = useLokeeHistoryStore((s) => s.setOriginalVersionId);
const setTargetVersionId = useLokeeHistoryStore((s) => s.setTargetVersionId);
const swapSides = useLokeeHistoryStore((s) => s.swapSides);

const newestFirst = useMemo(() => sortVersionsNewestFirst(versions), [versions]);
const resolved = useMemo(
() => resolveHistoryCompare(versions, { originalVersionId, targetVersionId }),
[versions, originalVersionId, targetVersionId]
);
const olderTargets = newestFirst.filter((v) => v.id !== resolved.latest?.id);
const sameSides =
Boolean(resolved.original && resolved.target && resolved.original.id === resolved.target.id);

return (
<div className="grid grid-cols-1 xl:grid-cols-11 gap-2 items-stretch" data-testid="lokee-history-compare-bar">
<div className="xl:col-span-5 bg-slate-950/60 p-2 rounded-md border border-slate-800/80 flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<div className="text-[10px] font-bold uppercase tracking-wider text-cyan-500/80">
Original
</div>
<div className="text-[10px] text-slate-500">Select a version</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<select
data-testid="lokee-database-select"
value={databaseId ?? ''}
disabled={databases.length === 0}
onChange={(e) => setDatabaseId(e.target.value || null)}
title="History database"
className="min-w-0 flex-1 text-xs bg-slate-900 border border-slate-700/60 rounded px-2 py-1 text-slate-200 focus:outline-none focus:border-cyan-500 truncate disabled:opacity-50"
>
{databases.length === 0 && <option value="">No captures yet</option>}
{databases.map((d) => (
<option key={d.id} value={d.id}>
{lokeeDatabaseLabel(d)}
</option>
))}
</select>
<select
data-testid="lokee-original-version"
value={resolved.original?.id ?? ''}
disabled={newestFirst.length === 0}
onChange={(e) => setOriginalVersionId(e.target.value || null)}
title="Baseline version (like Compare's Original Server)"
className="min-w-0 w-44 max-w-full text-xs bg-slate-900 border border-cyan-500/30 rounded px-2 py-1 text-cyan-100 focus:outline-none focus:border-cyan-500 truncate disabled:opacity-50"
>
{newestFirst.length === 0 && <option value="">No versions</option>}
{newestFirst.map((v) => (
<option key={v.id} value={v.id}>
{historyVersionLabel(v)}
</option>
))}
</select>
</div>
</div>

<div className="flex xl:col-span-1 justify-center items-center">
<button
type="button"
data-testid="lokee-history-swap-btn"
onClick={swapSides}
disabled={sameSides || newestFirst.length < 2}
title="Swap Original and Target"
className="group flex flex-col items-center gap-0.5 transition cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
<span className="text-[9px] font-bold uppercase tracking-wider text-cyan-500/70 group-hover:text-cyan-400">
Original
</span>
<ArrowRight className="w-5 h-5 text-indigo-500/80 group-hover:hidden transition" />
<ArrowLeftRight className="w-5 h-5 text-cyan-400 hidden group-hover:block" />
<span className="text-[9px] font-bold uppercase tracking-wider text-purple-400/70 group-hover:text-cyan-400">
Target
</span>
</button>
</div>

<div className="xl:col-span-5 bg-slate-950/60 p-2 rounded-md border border-slate-800/80 flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<div className="text-[10px] font-bold uppercase tracking-wider text-purple-400/80">
Target
</div>
<div className="text-[10px] text-slate-500">Current database or older version</div>
</div>
<select
data-testid="lokee-target-version"
value={resolved.targetIsCurrent ? '' : (resolved.target?.id ?? '')}
disabled={newestFirst.length === 0}
onChange={(e) => setTargetVersionId(e.target.value || null)}
title="Current live snapshot, or an older version"
className="w-full text-xs bg-slate-900 border border-purple-500/30 rounded px-2 py-1 text-purple-100 focus:outline-none focus:border-purple-500 truncate disabled:opacity-50"
>
{resolved.latest ? (
<option value="">{historyVersionLabel(resolved.latest, { current: true })}</option>
) : (
<option value="">Current database</option>
)}
{olderTargets.map((v) => (
<option key={v.id} value={v.id}>
{historyVersionLabel(v)}
</option>
))}
</select>
</div>
</div>
);
}
17 changes: 16 additions & 1 deletion apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface LokeeWeavePageProps {
) => Promise<void>;
/** Shorter header when shown inside Schema Sync. */
embedded?: boolean;
/** Original + Target version ids from the Compare-style History bar. */
compareVersionIds?: readonly string[];
}

const FILTERABLE_TYPES: LokeeObjectType[] = [
Expand Down Expand Up @@ -185,8 +187,15 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
onSelectObject,
onSaveVersionMeta,
embedded = false,
compareVersionIds,
}) => {
const [filters, setFilters] = useState<VersionGraphFilters>(() => freshFilters());
const [filters, setFilters] = useState<VersionGraphFilters>(() => {
const base = freshFilters();
if (compareVersionIds && compareVersionIds.length > 0) {
return { ...base, versionIds: new Set(compareVersionIds) };
}
return base;
});
const [locked, setLocked] = useState(true);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
Expand All @@ -198,6 +207,12 @@ export const LokeeWeavePage: React.FC<LokeeWeavePageProps> = ({
[dto.versions, selectedVersionId]
);

const compareKey = (compareVersionIds ?? []).join('|');
useEffect(() => {
if (!compareKey) return;
setFilters((f) => ({ ...f, versionIds: new Set(compareKey.split('|')) }));
}, [compareKey]);

useEffect(() => {
if (!selectedVersion) {
setEditName('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ vi.mock('./LokeeWeavePage', () => ({

import { LokeeWeaveView } from './LokeeWeaveView';
import { toast } from '../../store/toastStore';
import { useLokeeHistoryStore } from '../../store/lokeeHistoryStore';

const DB = {
id: 'db1',
Expand Down Expand Up @@ -88,6 +89,13 @@ beforeEach(() => {
loadVersionGraph.mockReset();
captureSchema.mockReset();
vi.mocked(toast).mockReset();
useLokeeHistoryStore.setState({
databaseId: null,
originalVersionId: null,
targetVersionId: null,
databases: [],
versions: [],
});
});

describe('LokeeWeaveView', () => {
Expand Down Expand Up @@ -168,6 +176,18 @@ describe('LokeeWeaveView', () => {
expect(loadVersionGraph).toHaveBeenCalledWith('db2', 20);
});

it('hides the in-graph database picker when embedded (toolbar owns Original → Target)', async () => {
listLokeeDatabases.mockResolvedValue([DB]);
loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false });

render(<LokeeWeaveView embedded />);

await waitFor(() => expect(screen.getByTestId('graph')).toBeTruthy());
expect(screen.queryByTestId('lokee-database-select')).toBeNull();
expect(useLokeeHistoryStore.getState().databaseId).toBe('db1');
expect(useLokeeHistoryStore.getState().versions.map((v) => v.id)).toEqual(['v2', 'v1']);
});

it('captures a schema from the chosen credential', async () => {
listLokeeDatabases.mockResolvedValueOnce([]).mockResolvedValue([DB]);
loadVersionGraph.mockResolvedValue({ ...DTO, truncatedObjects: false });
Expand Down
Loading
Loading