diff --git a/apps/e2e/src/pages/LokeeHistoryPage.ts b/apps/e2e/src/pages/LokeeHistoryPage.ts index d87b3cd4..1af4980d 100644 --- a/apps/e2e/src/pages/LokeeHistoryPage.ts +++ b/apps/e2e/src/pages/LokeeHistoryPage.ts @@ -12,7 +12,12 @@ export class LokeeHistoryPage { constructor(private page: Page) {} async openHistoryPane(): Promise { - 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); } diff --git a/apps/e2e/src/tests/smoke.test.ts b/apps/e2e/src/tests/smoke.test.ts index 02363be6..79c43b4a 100644 --- a/apps/e2e/src/tests/smoke.test.ts +++ b/apps/e2e/src/tests/smoke.test.ts @@ -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); }); }); diff --git a/apps/web/src/frontend/components/TopToolbar.tsx b/apps/web/src/frontend/components/TopToolbar.tsx index 9326518a..b5c7010f 100644 --- a/apps/web/src/frontend/components/TopToolbar.tsx +++ b/apps/web/src/frontend/components/TopToolbar.tsx @@ -17,6 +17,7 @@ import { useAuthStore } from '../store/authStore'; import { captureSchema } from '../api/lokeeApi'; import { toast } from '../store/toastStore'; import { getSessionPassword, setSessionPassword } from '../lib/sessionPasswords'; +import { HistoryCompareBar } from './lokee-weave/HistoryCompareBar'; const ProfileMenu = ProfileMenuNamed ?? ProfileMenuDefault; @@ -154,31 +155,31 @@ export const TopToolbar: React.FC = () => { ]; return ( -
+
{/* Brand + utilities. Workspace tabs sit on the next row so Lokee Weave cannot wrap under the logo and disappear in a narrow Cursor preview. */} -
- +
+ -
+
- {compareResult && activeView === 'sync' && ( + {compareResult && activeView === 'sync' && syncPane === 'compare' && ( @@ -189,11 +190,13 @@ export const TopToolbar: React.FC = () => {
- {/* Full-width workspace switcher — always its own row. */} - {(canSchemaBrowse || canSchemaCompare || canEditorAccess) && ( + {/* Full-width workspace switcher — hidden on History so the version + Original → Target bar can reuse Compare's mental model. */} + {(canSchemaBrowse || canSchemaCompare || canEditorAccess) && + !(activeView === 'sync' && syncPane === 'history') && (
Workspace @@ -202,26 +205,26 @@ export const TopToolbar: React.FC = () => { )} {canEditorAccess && ( )}
@@ -279,11 +282,13 @@ export const TopToolbar: React.FC = () => { {/* Database Connection Control Grid. Hidden in History: that pane compares two points in this database's own recorded past, so a live Original/Target pair says nothing about - what is on screen — History has its own version bar instead. */} + what is on screen — History gets the version bar in its place, which + is the same Original → Target gesture over stored versions. */} + {syncPane === 'history' && } {syncPane === 'compare' && (
{/* Source Configuration — left side is the Original Server (read / compare from). */} -
+
Original Server
@@ -295,7 +300,7 @@ export const TopToolbar: React.FC = () => { value={selectedSourceConnectionId ?? ''} onChange={(e) => e.target.value && selectSavedConnection('source', e.target.value)} title="Saved connections" - className="shrink-0 w-40 max-w-[160px] text-xs bg-slate-900 border border-slate-700/60 rounded px-2 py-1.5 text-slate-200 focus:outline-none focus:border-cyan-500 truncate" + className="shrink-0 w-36 max-w-[144px] 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" > {connections.map((c) => ( @@ -323,14 +328,14 @@ export const TopToolbar: React.FC = () => { setShowConnectionModal(true); }} title="Add or edit this connection's credentials" - className="shrink-0 text-xs font-semibold bg-slate-800 border border-slate-700 hover:bg-slate-700 hover:border-cyan-500/40 text-cyan-400 rounded transition cursor-pointer flex items-center gap-1.5 px-3 py-1.5" + className="shrink-0 text-xs font-semibold bg-slate-800 border border-slate-700 hover:bg-slate-700 hover:border-cyan-500/40 text-cyan-400 rounded transition cursor-pointer flex items-center gap-1.5 px-2 py-1" > {sourceConfig.option.database ? 'Edit' : 'Add'} Connection {isTestingSource ? ( - + Connecting... ) : sourceConnected ? ( @@ -338,7 +343,7 @@ export const TopToolbar: React.FC = () => { data-testid="source-connected-btn" onClick={testSourceConnection} title="Reconnect and refresh schema list" - className="group text-base text-emerald-400 bg-emerald-950/40 px-2.5 py-1 rounded-full border border-emerald-500/20 hover:border-emerald-400/50 hover:bg-emerald-950/70 flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" + className="group text-xs text-emerald-400 bg-emerald-950/40 px-2 py-0.5 rounded-full border border-emerald-500/20 hover:border-emerald-400/50 hover:bg-emerald-950/70 flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" > @@ -350,7 +355,7 @@ export const TopToolbar: React.FC = () => { data-testid="source-connect-btn" onClick={testSourceConnection} title="Retry connection" - className="text-base text-slate-400 hover:text-cyan-300 border border-slate-700 hover:border-cyan-500/40 bg-slate-900/60 hover:bg-slate-900 px-2.5 py-1 rounded-full flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" + className="text-xs text-slate-400 hover:text-cyan-300 border border-slate-700 hover:border-cyan-500/40 bg-slate-900/60 hover:bg-slate-900 px-2 py-0.5 rounded-full flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" > Retry Connection @@ -361,7 +366,7 @@ export const TopToolbar: React.FC = () => { onClick={() => browseSchema('source')} disabled={isBrowsing || selectedObjectTypes.length === 0} title="Load this schema's objects to browse and search (no comparison)" - className="shrink-0 text-base text-cyan-400 border border-cyan-500/30 hover:border-cyan-400/60 bg-cyan-950/30 hover:bg-cyan-950/60 px-2.5 py-1 rounded-full flex items-center gap-1 font-medium cursor-pointer transition disabled:opacity-50 disabled:cursor-not-allowed" + className="shrink-0 text-xs text-cyan-400 border border-cyan-500/30 hover:border-cyan-400/60 bg-cyan-950/30 hover:bg-cyan-950/60 px-2 py-0.5 rounded-full flex items-center gap-1 font-medium cursor-pointer transition disabled:opacity-50 disabled:cursor-not-allowed" > Browse @@ -379,8 +384,8 @@ export const TopToolbar: React.FC = () => { Original - - + + Target @@ -388,7 +393,7 @@ export const TopToolbar: React.FC = () => {
{/* Target Configuration */} -
+
Target
@@ -400,7 +405,7 @@ export const TopToolbar: React.FC = () => { value={selectedTargetConnectionId ?? ''} onChange={(e) => e.target.value && selectSavedConnection('target', e.target.value)} title="Saved connections" - className="shrink-0 w-40 max-w-[160px] text-xs bg-slate-900 border border-slate-700/60 rounded px-2 py-1.5 text-slate-200 focus:outline-none focus:border-purple-500 truncate" + className="shrink-0 w-36 max-w-[144px] text-xs bg-slate-900 border border-slate-700/60 rounded px-2 py-1 text-slate-200 focus:outline-none focus:border-purple-500 truncate" > {connections.map((c) => ( @@ -428,14 +433,14 @@ export const TopToolbar: React.FC = () => { setShowConnectionModal(true); }} title="Add or edit this connection's credentials" - className="shrink-0 text-xs font-semibold bg-slate-800 border border-slate-700 hover:bg-slate-700 hover:border-purple-500/40 text-purple-400 rounded transition cursor-pointer flex items-center gap-1.5 px-3 py-1.5" + className="shrink-0 text-xs font-semibold bg-slate-800 border border-slate-700 hover:bg-slate-700 hover:border-purple-500/40 text-purple-400 rounded transition cursor-pointer flex items-center gap-1.5 px-2 py-1" > {targetConfig.option.database ? 'Edit' : 'Add'} Connection {isTestingTarget ? ( - + Connecting... ) : targetConnected ? ( @@ -443,7 +448,7 @@ export const TopToolbar: React.FC = () => { data-testid="target-connected-btn" onClick={testTargetConnection} title="Reconnect and refresh schema list" - className="group text-base text-emerald-400 bg-emerald-950/40 px-2.5 py-1 rounded-full border border-emerald-500/20 hover:border-emerald-400/50 hover:bg-emerald-950/70 flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" + className="group text-xs text-emerald-400 bg-emerald-950/40 px-2 py-0.5 rounded-full border border-emerald-500/20 hover:border-emerald-400/50 hover:bg-emerald-950/70 flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" > @@ -455,7 +460,7 @@ export const TopToolbar: React.FC = () => { data-testid="target-connect-btn" onClick={testTargetConnection} title="Retry connection" - className="text-base text-slate-400 hover:text-purple-300 border border-slate-700 hover:border-purple-500/40 bg-slate-900/60 hover:bg-slate-900 px-2.5 py-1 rounded-full flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" + className="text-xs text-slate-400 hover:text-purple-300 border border-slate-700 hover:border-purple-500/40 bg-slate-900/60 hover:bg-slate-900 px-2 py-0.5 rounded-full flex items-center gap-1 font-medium shrink-0 cursor-pointer transition" > Retry Connection @@ -466,7 +471,7 @@ export const TopToolbar: React.FC = () => { onClick={() => browseSchema('target')} disabled={isBrowsing || selectedObjectTypes.length === 0} title="Load this schema's objects to browse and search (no comparison)" - className="shrink-0 text-base text-purple-300 border border-purple-500/30 hover:border-purple-400/60 bg-purple-950/30 hover:bg-purple-950/60 px-2.5 py-1 rounded-full flex items-center gap-1 font-medium cursor-pointer transition disabled:opacity-50 disabled:cursor-not-allowed" + className="shrink-0 text-xs text-purple-300 border border-purple-500/30 hover:border-purple-400/60 bg-purple-950/30 hover:bg-purple-950/60 px-2 py-0.5 rounded-full flex items-center gap-1 font-medium cursor-pointer transition disabled:opacity-50 disabled:cursor-not-allowed" > Browse @@ -477,24 +482,24 @@ export const TopToolbar: React.FC = () => { )} {syncPane === 'compare' && ( -
+
{/* Scope Config Controls — two always-separate rows: which object types get compared (top), and which of the results are shown (bottom, once a compare has run). Each is its own flex-wrap line so the label always stays attached to its own pills. */}
- + Comparison Scope: -
+
{objectScopeOptions.map((opt) => { const active = selectedObjectTypes.includes(opt.type); return ( +
+ +
+
+
+ Target +
+
Current database or older version
+
+ +
+
+ ); +} diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx index 2e13a0f9..371267cf 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeWeavePage.tsx @@ -58,6 +58,8 @@ export interface LokeeWeavePageProps { ) => Promise; /** 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[] = [ @@ -185,8 +187,15 @@ export const LokeeWeavePage: React.FC = ({ onSelectObject, onSaveVersionMeta, embedded = false, + compareVersionIds, }) => { - const [filters, setFilters] = useState(() => freshFilters()); + const [filters, setFilters] = useState(() => { + 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(null); const [editName, setEditName] = useState(''); @@ -198,6 +207,12 @@ export const LokeeWeavePage: React.FC = ({ [dto.versions, selectedVersionId] ); + const compareKey = (compareVersionIds ?? []).join('|'); + useEffect(() => { + if (!compareKey) return; + setFilters((f) => ({ ...f, versionIds: new Set(compareKey.split('|')) })); + }, [compareKey]); + useEffect(() => { if (!selectedVersion) { setEditName(''); diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.test.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.test.tsx index 84052a3f..8a5d5013 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.test.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.test.tsx @@ -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', @@ -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', () => { @@ -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(); + + 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 }); diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx index 2ef84edd..4fe07a03 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeWeaveView.tsx @@ -27,6 +27,8 @@ import { getSessionPassword } from '../../lib/sessionPasswords'; import { toast } from '../../store/toastStore'; import { useSyncStore } from '../../store/useSyncStore'; import { useUiStore } from '../../store/uiStore'; +import { useLokeeHistoryStore } from '../../store/lokeeHistoryStore'; +import { lokeeDatabaseLabel, resolveHistoryCompare } from '../../lib/historyCompare'; import { SQL_ICON_STROKE } from '../sql-editor/sqlIconStyle'; export interface LokeeWeaveViewProps { @@ -55,12 +57,6 @@ function describe(database: LokeeDatabase | undefined): string | undefined { return `[${database.dialect}] ${where}${schema}`; } -function databaseLabel(database: LokeeDatabase): string { - const where = [database.host, database.database].filter(Boolean).join('/') || database.id.slice(0, 8); - const schema = database.schema ? ` · ${database.schema}` : ''; - return `${database.dialect.toUpperCase()} · ${where}${schema} (${database.versionCount} v)`; -} - export function LokeeWeaveView({ databaseId, versionLimit = 20, @@ -72,8 +68,13 @@ export function LokeeWeaveView({ const targetConfig = useSyncStore((s) => s.targetConfig); const lokeeEpoch = useUiStore((s) => s.lokeeEpoch); const bumpLokeeEpoch = useUiStore((s) => s.bumpLokeeEpoch); + const storeDatabaseId = useLokeeHistoryStore((s) => s.databaseId); + const setStoreDatabaseId = useLokeeHistoryStore((s) => s.setDatabaseId); + const setStoreDatabases = useLokeeHistoryStore((s) => s.setDatabases); + const setStoreVersions = useLokeeHistoryStore((s) => s.setVersions); + const originalVersionId = useLokeeHistoryStore((s) => s.originalVersionId); + const targetVersionId = useLokeeHistoryStore((s) => s.targetVersionId); const [databases, setDatabases] = useState([]); - const [pickedId, setPickedId] = useState(undefined); const [captureConnectionId, setCaptureConnectionId] = useState(''); const [dto, setDto] = useState(EMPTY_DTO); const [loading, setLoading] = useState(true); @@ -82,9 +83,8 @@ export function LokeeWeaveView({ const [selectedObject, setSelectedObject] = useState(null); // Bumped to re-run the effect; a plain refetch() would race the in-flight one. const [reloadToken, setReloadToken] = useState(0); - // The two sides of the version bar, and the pair currently being compared. - const [originalVersionId, setOriginalVersionId] = useState(); - const [targetVersionId, setTargetVersionId] = useState(); + // Which pair the modal is showing. The two *sides* live in the history store, + // because the picker that sets them is HistoryCompareBar up in the toolbar. const [comparePair, setComparePair] = useState<{ original: string; target: string } | null>(null); const matchedTargetId = useMemo(() => { @@ -101,8 +101,39 @@ export function LokeeWeaveView({ )?.id; }, [databases, targetConfig]); - // Prop wins; otherwise the user's pick; otherwise the Target database; else most recent. - const activeId = databaseId ?? pickedId ?? matchedTargetId ?? databases[0]?.id; + const storedId = + storeDatabaseId && + (databases.length === 0 || databases.some((d) => d.id === storeDatabaseId)) + ? storeDatabaseId + : undefined; + // Prop wins; otherwise the History Original picker; otherwise Target; else most recent. + const activeId = databaseId ?? storedId ?? matchedTargetId ?? databases[0]?.id; + + useEffect(() => { + setStoreDatabases(databases); + }, [databases, setStoreDatabases]); + + useEffect(() => { + if (databaseId) return; + if (activeId && storeDatabaseId !== activeId) setStoreDatabaseId(activeId); + }, [databaseId, activeId, storeDatabaseId, setStoreDatabaseId]); + + useEffect(() => { + setStoreVersions( + dto.versions.map((v) => ({ id: v.id, number: v.number, name: v.name })) + ); + }, [dto.versions, setStoreVersions]); + + const compareVersionIds = useMemo(() => { + const resolved = resolveHistoryCompare( + dto.versions.map((v) => ({ id: v.id, number: v.number, name: v.name })), + { originalVersionId, targetVersionId } + ); + const ids = [resolved.original?.id, resolved.target?.id].filter( + (id): id is string => Boolean(id) + ); + return [...new Set(ids)]; + }, [dto.versions, originalVersionId, targetVersionId]); useEffect(() => { if (!captureConnectionId && selectedTargetConnectionId) { @@ -214,7 +245,7 @@ export function LokeeWeaveView({ password: getSessionPassword(captureConnectionId) || undefined, source: 'manual', }); - setPickedId(result.databaseId); + setStoreDatabaseId(result.databaseId); refresh(); bumpLokeeEpoch(); toast({ @@ -235,30 +266,32 @@ export function LokeeWeaveView({ } finally { setCapturing(false); } - }, [captureConnectionId, capturing, connections, refresh, bumpLokeeEpoch]); + }, [captureConnectionId, capturing, connections, refresh, bumpLokeeEpoch, setStoreDatabaseId]); const chrome = (
+ {!embedded && ( + )} -
- + + Original and Target are set in the bar above. +
); @@ -439,8 +406,8 @@ export function LokeeWeaveView({
Take an initial snapshot of the Target (Snapshot target in the toolbar), or pick a credential here and click Capture schema. - Migrating the Target records a new version automatically. Unchanged objects are stored - once and reused by hash pointer. + Then choose an Original version and a Target (current database or an older version), + the same way as Compare. Migrating the Target records a new version automatically.
@@ -463,6 +430,7 @@ export function LokeeWeaveView({ dto={dto} subtitle={subtitle} embedded={embedded} + compareVersionIds={compareVersionIds} onSelectObject={handleSelectObject} onSaveVersionMeta={saveVersionMeta} /> diff --git a/apps/web/src/frontend/lib/historyCompare.test.ts b/apps/web/src/frontend/lib/historyCompare.test.ts new file mode 100644 index 00000000..b6cdd74f --- /dev/null +++ b/apps/web/src/frontend/lib/historyCompare.test.ts @@ -0,0 +1,110 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { + historyVersionLabel, + lokeeDatabaseLabel, + resolveHistoryCompare, + swapHistoryCompare, +} from './historyCompare'; + +const v1 = { id: 'v1', number: 1, name: 'Initial' }; +const v2 = { id: 'v2', number: 2 }; +const v3 = { id: 'v3', number: 3, name: 'After migrate' }; + +describe('resolveHistoryCompare', () => { + it('returns empty sides when there are no versions', () => { + const resolved = resolveHistoryCompare([], { originalVersionId: null, targetVersionId: null }); + expect(resolved.original).toBeNull(); + expect(resolved.target).toBeNull(); + expect(resolved.targetIsCurrent).toBe(true); + }); + + it('uses the only version as both Original and current Target', () => { + const resolved = resolveHistoryCompare([v1], { originalVersionId: null, targetVersionId: null }); + expect(resolved.original).toEqual(v1); + expect(resolved.target).toEqual(v1); + expect(resolved.targetIsCurrent).toBe(true); + }); + + it('defaults Original to the previous version and Target to current', () => { + const resolved = resolveHistoryCompare([v1, v3, v2], { + originalVersionId: null, + targetVersionId: null, + }); + expect(resolved.latest?.id).toBe('v3'); + expect(resolved.original?.id).toBe('v2'); + expect(resolved.target?.id).toBe('v3'); + expect(resolved.targetIsCurrent).toBe(true); + }); + + it('honours an explicit Original and an older Target', () => { + const resolved = resolveHistoryCompare([v1, v2, v3], { + originalVersionId: 'v3', + targetVersionId: 'v1', + }); + expect(resolved.original?.id).toBe('v3'); + expect(resolved.target?.id).toBe('v1'); + expect(resolved.targetIsCurrent).toBe(false); + }); + + it('falls back when stored ids are stale', () => { + const resolved = resolveHistoryCompare([v1, v2], { + originalVersionId: 'gone', + targetVersionId: 'also-gone', + }); + expect(resolved.original?.id).toBe('v1'); + expect(resolved.target?.id).toBe('v2'); + expect(resolved.targetIsCurrent).toBe(true); + }); +}); + +describe('swapHistoryCompare', () => { + it('is a no-op when both sides resolve to the same version', () => { + const selection = { originalVersionId: null, targetVersionId: null }; + expect(swapHistoryCompare([v1], selection)).toEqual(selection); + }); + + it('swaps previous → current into current → older version', () => { + const swapped = swapHistoryCompare([v1, v2], { + originalVersionId: null, + targetVersionId: null, + }); + expect(swapped.originalVersionId).toBe('v2'); + expect(swapped.targetVersionId).toBe('v1'); + }); + + it('marks Target as current when the swapped-in version is latest', () => { + const swapped = swapHistoryCompare([v1, v2], { + originalVersionId: 'v2', + targetVersionId: 'v1', + }); + expect(swapped.originalVersionId).toBe('v1'); + expect(swapped.targetVersionId).toBeNull(); + }); +}); + +describe('labels', () => { + it('labels the latest snapshot as the current database', () => { + expect(historyVersionLabel(v3, { current: true })).toBe( + 'Current database (v3 · After migrate)' + ); + expect(historyVersionLabel(v2)).toBe('Version 2'); + }); + + it('names a history database the same way the picker does', () => { + expect( + lokeeDatabaseLabel({ + id: 'db1', + dialect: 'postgres', + host: 'localhost', + database: 'foxdb', + schema: 'public', + versionCount: 3, + }) + ).toBe('POSTGRES · localhost/foxdb · public (3 v)'); + }); +}); diff --git a/apps/web/src/frontend/lib/historyCompare.ts b/apps/web/src/frontend/lib/historyCompare.ts new file mode 100644 index 00000000..85ab570d --- /dev/null +++ b/apps/web/src/frontend/lib/historyCompare.ts @@ -0,0 +1,95 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * History reuses Compare's Original → Target model: + * Original = a captured version (the baseline) + * Target = the current database (latest snapshot) or an older version + */ + +export interface HistoryVersionOption { + id: string; + number: number; + name?: string; +} + +export interface HistoryCompareSelection { + /** Null means "previous version" (or the only version). */ + originalVersionId: string | null; + /** Null means "current database" — the latest captured version. */ + targetVersionId: string | null; +} + +export interface ResolvedHistoryCompare { + original: HistoryVersionOption | null; + target: HistoryVersionOption | null; + targetIsCurrent: boolean; + latest: HistoryVersionOption | null; + previous: HistoryVersionOption | null; +} + +export function sortVersionsNewestFirst(versions: readonly T[]): T[] { + return [...versions].sort((a, b) => b.number - a.number); +} + +export function resolveHistoryCompare( + versions: readonly HistoryVersionOption[], + selection: HistoryCompareSelection +): ResolvedHistoryCompare { + const newestFirst = sortVersionsNewestFirst(versions); + const latest = newestFirst[0] ?? null; + const previous = newestFirst[1] ?? latest; + const original = + (selection.originalVersionId + ? newestFirst.find((v) => v.id === selection.originalVersionId) + : undefined) ?? + previous ?? + null; + const targetExplicit = selection.targetVersionId + ? newestFirst.find((v) => v.id === selection.targetVersionId) + : undefined; + const target = targetExplicit ?? latest; + const targetIsCurrent = selection.targetVersionId == null || target?.id === latest?.id; + return { original, target, targetIsCurrent, latest, previous }; +} + +/** Swap Original ↔ Target, keeping "current database" when the new target is latest. */ +export function swapHistoryCompare( + versions: readonly HistoryVersionOption[], + selection: HistoryCompareSelection +): HistoryCompareSelection { + const resolved = resolveHistoryCompare(versions, selection); + if (!resolved.original || !resolved.target) return selection; + if (resolved.original.id === resolved.target.id) return selection; + const latestId = resolved.latest?.id; + return { + originalVersionId: resolved.target.id, + targetVersionId: resolved.original.id === latestId ? null : resolved.original.id, + }; +} + +export function historyVersionLabel( + version: HistoryVersionOption, + opts?: { current?: boolean } +): string { + const custom = version.name?.trim(); + const base = custom ? `v${version.number} · ${custom}` : `Version ${version.number}`; + return opts?.current ? `Current database (${base})` : base; +} + +export function lokeeDatabaseLabel(database: { + id: string; + dialect: string; + host?: string; + database?: string; + schema?: string; + versionCount?: number; +}): string { + const where = + [database.host, database.database].filter(Boolean).join('/') || database.id.slice(0, 8); + const schema = database.schema ? ` · ${database.schema}` : ''; + const versions = + typeof database.versionCount === 'number' ? ` (${database.versionCount} v)` : ''; + return `${database.dialect.toUpperCase()} · ${where}${schema}${versions}`; +} diff --git a/apps/web/src/frontend/store/lokeeHistoryStore.ts b/apps/web/src/frontend/store/lokeeHistoryStore.ts new file mode 100644 index 00000000..7fdb8852 --- /dev/null +++ b/apps/web/src/frontend/store/lokeeHistoryStore.ts @@ -0,0 +1,54 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Session state for History's Compare-style Original → Target pickers. + * Not persisted — each visit defaults to previous version → current database. + */ +import { create } from 'zustand'; +import type { LokeeDatabase } from '../api/lokeeApi'; +import { + swapHistoryCompare, + type HistoryVersionOption, +} from '../lib/historyCompare'; + +interface LokeeHistoryState { + databaseId: string | null; + originalVersionId: string | null; + targetVersionId: string | null; + databases: LokeeDatabase[]; + versions: HistoryVersionOption[]; + setDatabaseId: (id: string | null) => void; + setOriginalVersionId: (id: string | null) => void; + setTargetVersionId: (id: string | null) => void; + setDatabases: (rows: LokeeDatabase[]) => void; + setVersions: (rows: HistoryVersionOption[]) => void; + swapSides: () => void; +} + +export const useLokeeHistoryStore = create((set, get) => ({ + databaseId: null, + originalVersionId: null, + targetVersionId: null, + databases: [], + versions: [], + setDatabaseId: (databaseId) => { + if (get().databaseId === databaseId) return; + set({ databaseId, originalVersionId: null, targetVersionId: null }); + }, + setOriginalVersionId: (originalVersionId) => set({ originalVersionId }), + setTargetVersionId: (targetVersionId) => set({ targetVersionId }), + setDatabases: (databases) => set({ databases }), + setVersions: (versions) => set({ versions }), + swapSides: () => { + const next = swapHistoryCompare(get().versions, { + originalVersionId: get().originalVersionId, + targetVersionId: get().targetVersionId, + }); + set({ + originalVersionId: next.originalVersionId, + targetVersionId: next.targetVersionId, + }); + }, +}));