diff --git a/apps/e2e/src/tests/schema-history.test.ts b/apps/e2e/src/tests/schema-history.test.ts index 6282bae1..61c9233f 100644 --- a/apps/e2e/src/tests/schema-history.test.ts +++ b/apps/e2e/src/tests/schema-history.test.ts @@ -161,6 +161,8 @@ describe.skipIf(!ready)('Schema Sync · History (SQLite)', () => { expect(await driver.locator('[data-testid="lokee-inspector-column-mutations"]').isVisible()).toBe( true ); - expect(await driver.locator('[data-testid="lokee-inspector-revert-1"]').isVisible()).toBe(true); + // The roadmap keeps the version that created the table reachable even + // though the panel now defaults to the versions that touched it. + expect(await driver.locator('[data-testid="lokee-inspector-version-1"]').isVisible()).toBe(true); }); }); diff --git a/apps/web/src/backend/api/file-browse.test.ts b/apps/web/src/backend/api/file-browse.test.ts new file mode 100644 index 00000000..7205b839 --- /dev/null +++ b/apps/web/src/backend/api/file-browse.test.ts @@ -0,0 +1,138 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * The rule these encode: this endpoint names database files and directories, + * and nothing else. Every test that adds a file type is asking whether it + * would still be true. + */ +import { mkdtemp, mkdir, writeFile, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + browseDirectory, + browseErrorMessage, + isDatabaseFile, + parentOf, + resolveBrowsePath, +} from './file-browse'; + +const root = await mkdtemp(join(tmpdir(), 'fox-browse-')); + +await mkdir(join(root, 'projects')); +await mkdir(join(root, '.hidden')); +await writeFile(join(root, 'app.db'), 'x'); +await writeFile(join(root, 'analytics.duckdb'), 'x'); +await writeFile(join(root, 'Notes.SQLite3'), 'x'); +await writeFile(join(root, 'secrets.env'), 'AWS_SECRET=1'); +await writeFile(join(root, 'id_rsa'), 'PRIVATE KEY'); +await writeFile(join(root, 'dump.sql'), 'select 1'); +await writeFile(join(root, '.env'), 'TOKEN=1'); + +afterAll(() => { + /* tmpdir is the OS's to clean */ +}); + +describe('isDatabaseFile', () => { + it.each([ + ['app.db', true], + ['app.DB', true], + ['store.sqlite', true], + ['store.sqlite3', true], + ['warehouse.duckdb', true], + ['x.ddb', true], + ['dump.sql', false], + ['secrets.env', false], + ['id_rsa', false], + ['db', false], + ['notes.dbx', false], + ])('%s → %s', (name, want) => { + expect(isDatabaseFile(name)).toBe(want); + }); +}); + +describe('browseDirectory', () => { + it('lists directories and database files, and nothing else', async () => { + const result = await browseDirectory(root); + const names = result.entries.map((e) => e.name); + expect(names).toContain('projects'); + expect(names).toContain('app.db'); + expect(names).toContain('analytics.duckdb'); + expect(names).toContain('Notes.SQLite3'); + // The point of the filter: a signed-in user cannot use this to enumerate + // keys, dumps or dotfiles. + expect(names).not.toContain('secrets.env'); + expect(names).not.toContain('id_rsa'); + expect(names).not.toContain('dump.sql'); + expect(names).not.toContain('.env'); + expect(names).not.toContain('.hidden'); + }); + + it('never returns file contents — only name, size and mtime', async () => { + const file = (await browseDirectory(root)).entries.find((e) => e.name === 'app.db'); + expect(Object.keys(file!).sort()).toEqual(['kind', 'modifiedAt', 'name', 'path', 'size']); + }); + + it('puts directories before files, each sorted case-insensitively', async () => { + const kinds = (await browseDirectory(root)).entries.map((e) => e.kind); + expect(kinds.indexOf('dir')).toBeLessThan(kinds.indexOf('file')); + const files = (await browseDirectory(root)).entries.filter((e) => e.kind === 'file'); + expect(files.map((f) => f.name)).toEqual(['analytics.duckdb', 'app.db', 'Notes.SQLite3']); + }); + + it('lists the containing directory when handed a file', async () => { + // The picker reopens on the last used path, which is a file. + const result = await browseDirectory(join(root, 'app.db')); + expect(result.path).toBe(root); + }); + + it('reports a parent for a nested directory and null at the root', async () => { + expect((await browseDirectory(join(root, 'projects'))).parent).toBe(root); + expect(parentOf('/')).toBeNull(); + }); + + it('rejects a path with a NUL byte instead of normalizing it', () => { + const home = '/home/someone'; + expect(resolveBrowsePath('/etc\0/../../root', home)).toBe(home); + }); + + it('resolves a relative path against home, not the process cwd', () => { + // cwd is wherever the service was started from — not a place the user has + // any model of. + expect(resolveBrowsePath('data', '/home/someone')).toBe('/home/someone/data'); + expect(resolveBrowsePath('', '/home/someone')).toBe('/home/someone'); + expect(resolveBrowsePath(undefined, '/home/someone')).toBe('/home/someone'); + }); + + it('follows a symlinked directory', async () => { + const link = join(root, 'linked'); + await symlink(join(root, 'projects'), link, 'dir'); + expect((await browseDirectory(link)).entries).toEqual([]); + }); + + it('throws for a missing directory, with a message that names it', async () => { + const missing = join(root, 'nope'); + await expect(browseDirectory(missing)).rejects.toThrow(); + await expect( + browseDirectory(missing).catch((e: unknown) => browseErrorMessage(e, missing)) + ).resolves.toContain('No such directory'); + }); +}); + +describe('browseErrorMessage', () => { + it.each([ + ['ENOENT', 'No such directory'], + ['EACCES', 'Permission denied'], + ['EPERM', 'Permission denied'], + ['ENOTDIR', 'Not a directory'], + ])('%s reads as "%s"', (code, expected) => { + const err = Object.assign(new Error('raw'), { code }); + expect(browseErrorMessage(err, '/some/dir')).toContain(expected); + }); + + it('falls back to the error message for anything else', () => { + expect(browseErrorMessage(new Error('disk on fire'), '/x')).toBe('disk on fire'); + }); +}); diff --git a/apps/web/src/backend/api/file-browse.ts b/apps/web/src/backend/api/file-browse.ts new file mode 100644 index 00000000..a1ef15e8 --- /dev/null +++ b/apps/web/src/backend/api/file-browse.ts @@ -0,0 +1,155 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Directory listing for the SQLite / DuckDB file picker. + * + * The browser cannot hand the server a real path — an OS file dialog gives a + * `File` with a name and no location — so a database file that lives on the + * machine running Fox Schema can only be picked by listing that machine. + * + * What this deliberately is *not*: a file manager. It returns names, sizes and + * mtimes, never contents, and the only files it names are ones a database + * driver could open. A signed-in user could already reach any of these by + * typing the path into the connection form; this makes that discoverable + * without widening what they can actually do with it. + */ +import { readdir, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, dirname, isAbsolute, resolve, sep } from 'node:path'; + +/** Extensions a SQLite or DuckDB connection could actually open. */ +export const DATABASE_FILE_EXTENSIONS = [ + '.db', + '.db3', + '.sqlite', + '.sqlite3', + '.duckdb', + '.ddb', +] as const; + +export interface FileBrowseEntry { + name: string; + path: string; + kind: 'dir' | 'file'; + /** Files only. */ + size?: number; + modifiedAt?: string; +} + +export interface FileBrowseResult { + /** The directory that was listed, absolute and normalized. */ + path: string; + /** Parent directory, or null at the filesystem root. */ + parent: string | null; + /** Where "Home" jumps to, so the client does not have to guess. */ + home: string; + entries: FileBrowseEntry[]; + /** True when the listing was cut at the cap — the client says so. */ + truncated: boolean; +} + +/** Directories with thousands of entries are a UI hazard, not a feature. */ +const MAX_ENTRIES = 500; + +export function isDatabaseFile(name: string): boolean { + const lower = name.toLowerCase(); + return DATABASE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +/** + * Resolve the requested directory. + * + * A relative or empty path resolves against the home directory rather than the + * server process's cwd: cwd is wherever the service happened to be started + * from, which is not a place the user has any model of. + */ +export function resolveBrowsePath(raw: string | undefined, home = homedir()): string { + const trimmed = (raw ?? '').trim(); + if (!trimmed) return home; + // A NUL byte truncates the path inside libc — reject rather than normalize, + // because "/safe\0/../../etc" is two different paths depending on who reads it. + if (trimmed.includes('\0')) return home; + return isAbsolute(trimmed) ? resolve(trimmed) : resolve(home, trimmed); +} + +/** Parent of `dir`, or null once dirname stops moving (the root). */ +export function parentOf(dir: string): string | null { + const parent = dirname(dir); + return parent === dir ? null : parent; +} + +/** + * List one directory: sub-directories, then database files. + * + * Hidden entries are skipped — a `.git` or `.cache` full of nothing openable is + * noise — but a user who types a dotted path can still browse into it, because + * the filter applies to what is listed, not to where you may go. + */ +export async function browseDirectory(rawPath?: string): Promise { + const home = homedir(); + const path = resolveBrowsePath(rawPath, home); + + // eslint-disable-next-line security/detect-non-literal-fs-filename -- the user-supplied path IS the feature; it is resolved above, rejected if it carries a NUL, and only ever stat'd or listed, never read or written + const info = await stat(path); + if (!info.isDirectory()) { + // Being handed a file is normal: the picker reopens on the last used path, + // which is a file. List where it lives. + return browseDirectory(dirname(path)); + } + + // eslint-disable-next-line security/detect-non-literal-fs-filename -- same path, already resolved and validated; listing names is the whole operation + const dirents = await readdir(path, { withFileTypes: true }); + const dirs: FileBrowseEntry[] = []; + const files: FileBrowseEntry[] = []; + + for (const dirent of dirents) { + if (dirent.name.startsWith('.')) continue; + const full = path.endsWith(sep) ? `${path}${dirent.name}` : `${path}${sep}${dirent.name}`; + if (dirent.isDirectory()) { + dirs.push({ name: dirent.name, path: full, kind: 'dir' }); + continue; + } + // A symlink to a database file is still a database file; a symlink to a + // directory is followed on click, by the same stat as any other path. + if (!dirent.isFile() && !dirent.isSymbolicLink()) continue; + if (!isDatabaseFile(dirent.name)) continue; + let size: number | undefined; + let modifiedAt: string | undefined; + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- `full` is a child of the directory just listed, not caller input + const fileInfo = await stat(full); + if (fileInfo.isDirectory()) continue; + size = fileInfo.size; + modifiedAt = fileInfo.mtime.toISOString(); + } catch { + // A dangling symlink or a file removed mid-listing: still worth naming, + // just without its details. + } + files.push({ name: dirent.name, path: full, kind: 'file', size, modifiedAt }); + } + + const byName = (a: FileBrowseEntry, b: FileBrowseEntry) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + dirs.sort(byName); + files.sort(byName); + + const all = [...dirs, ...files]; + return { + path, + parent: parentOf(path), + home, + entries: all.slice(0, MAX_ENTRIES), + truncated: all.length > MAX_ENTRIES, + }; +} + +/** Human-readable reason a directory could not be listed. */ +export function browseErrorMessage(error: unknown, path: string): string { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === 'ENOENT') return `No such directory: ${path}`; + if (code === 'EACCES' || code === 'EPERM') return `Permission denied: ${path}`; + if (code === 'ENOTDIR') return `Not a directory: ${basename(path)}`; + return error instanceof Error ? error.message : 'Failed to list directory'; +} diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 514d7b0c..336f7198 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -33,6 +33,7 @@ import { isSingleSqlStatement } from './single-statement'; import { AppSettingsStore } from '../modules/app-settings.module'; import { LokeeWeaveStore } from '../modules/lokee-weave.module'; import { rateLimit } from './rate-limit'; +import { browseDirectory, browseErrorMessage, resolveBrowsePath } from './file-browse'; import { runStatements, clampMaxRows, @@ -944,6 +945,28 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt } ); + // Directory listing for the SQLite / DuckDB file picker. Read-only and + // name-only: it never returns file contents, and only names files a database + // driver could open. `schema.browse` because picking a database file is the + // first step of browsing one. + const fileBrowseLimiter = rateLimit({ windowMs: 60 * 1000, max: 60 }); + + router.get( + '/files/browse', + fileBrowseLimiter, + requirePermissions('schema.browse'), + async (req: Request, res: Response) => { + const requested = typeof req.query.path === 'string' ? req.query.path : undefined; + try { + res.json(await browseDirectory(requested)); + } catch (error: unknown) { + // The path is echoed back resolved, so the message names the directory + // the server actually tried rather than the raw query string. + res.status(400).json({ error: browseErrorMessage(error, resolveBrowsePath(requested)) }); + } + } + ); + router.get('/lokee/databases', async (req: Request, res: Response) => { res.json({ databases: await lokeeWeave.listDatabases((req as AuthedRequest).userId!) }); }); diff --git a/apps/web/src/backend/api/sql-execute.ts b/apps/web/src/backend/api/sql-execute.ts index 8b72d383..94a577c2 100644 --- a/apps/web/src/backend/api/sql-execute.ts +++ b/apps/web/src/backend/api/sql-execute.ts @@ -1,4 +1,5 @@ import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/db'; +import { autoAliasSelectColumns } from '@foxschema/sql'; import { isPageableStatement, trimPageProbe, wrapSqlForPage } from './sql-page-wrap'; /** @@ -110,8 +111,15 @@ export async function runStatements( } const results: StatementResult[] = []; - for (const [index, sql] of statements.entries()) { + for (const [index, original] of statements.entries()) { const started = Date.now(); + // Name any SELECT expression the user left unaliased. The grid keys its + // columns off the row object, and Postgres calls every unaliased + // expression `?column?` while SQL Server leaves them unnamed — so + // `SELECT 1, 2` arrived as a single key and one column silently vanished. + // The rewrite is conservative and returns the statement untouched + // whenever it cannot parse the select list with confidence. + const sql = autoAliasSelectColumns(original).sql; // Placeholders survive the paging wrap (it only nests the SQL in a // subquery), so the same positional params apply on either path. const params = paramsList[index] ?? []; diff --git a/apps/web/src/frontend/api/fileApi.ts b/apps/web/src/frontend/api/fileApi.ts new file mode 100644 index 00000000..e77092fe --- /dev/null +++ b/apps/web/src/frontend/api/fileApi.ts @@ -0,0 +1,31 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Browse directories on the machine running the backend, for picking a SQLite + * or DuckDB file. Names only — this endpoint never returns file contents. + */ +import { getApiBase, parseJsonResponse } from './apiBase'; + +export interface FileBrowseEntry { + name: string; + path: string; + kind: 'dir' | 'file'; + size?: number; + modifiedAt?: string; +} + +export interface FileBrowseResult { + path: string; + parent: string | null; + home: string; + entries: FileBrowseEntry[]; + truncated: boolean; +} + +export async function browseFiles(path?: string): Promise { + const query = path ? `?path=${encodeURIComponent(path)}` : ''; + const res = await fetch(`${getApiBase()}/files/browse${query}`, { credentials: 'include' }); + return parseJsonResponse(res); +} diff --git a/apps/web/src/frontend/components/ConnectionModal.tsx b/apps/web/src/frontend/components/ConnectionModal.tsx index 1cfce115..f60529cb 100644 --- a/apps/web/src/frontend/components/ConnectionModal.tsx +++ b/apps/web/src/frontend/components/ConnectionModal.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from "react"; import { createPortal } from "react-dom"; -import { X, CheckCircle, AlertTriangle, Loader2, ListTree, Download } from "lucide-react"; +import { X, CheckCircle, AlertTriangle, FolderOpen, Loader2, ListTree, Download } from "lucide-react"; import { type ConnectionOptions, type Dialect, buildConnectionString, DEFAULT_PORTS, getProviderSettings, PROVIDER_SETTINGS } from '../lib/provider-settings'; import type { DriverInfo } from '../lib/types'; import { fetchSchemaList, checkDriver as apiCheckDriver, installDriver as apiInstallDriver } from "../api/schemaApi"; import { PasswordInput } from './PasswordInput'; +import { DatabaseFilePicker } from './DatabaseFilePicker'; interface CredentialInput { @@ -68,7 +69,14 @@ export const ConnectionModal: React.FC = ({ const [schemaList, setSchemaList] = useState([]); const schemaRequired = getProviderSettings(selDialect).schemaRequired; - + /** + * SQLite and DuckDB are a file on disk, not a server. Host, port, user, + * password and SSL have no meaning for them, and showing the boxes anyway + * has people filling in `localhost` and wondering why it changes nothing. + */ + const isFileDialect = selDialect === 'sqlite' || selDialect === 'duckdb'; + + const [browsing, setBrowsing] = useState(false); const [driverInfo, setDriverInfo] = useState(null); const [installing, setInstalling] = useState(false); @@ -106,6 +114,7 @@ export const ConnectionModal: React.FC = ({ pool: { min: initialOptions?.pool?.min || 1, max: initialOptions?.pool?.max || 10 }, }); setSchemaList([]); + setBrowsing(false); // New credential → save password on by default; edit → match what's stored. setSavePassword(initialHasPassword ?? true); setTestingState({ status: 'idle' }); @@ -192,7 +201,7 @@ export const ConnectionModal: React.FC = ({ return; } - if (isCredential && savePassword && !form.password?.trim() && !initialHasPassword) { + if (isCredential && !isFileDialect && savePassword && !form.password?.trim() && !initialHasPassword) { setTestingState({ status: 'failed', error: 'Enter a password, or untick “Save password” to store the credential without one.', @@ -215,7 +224,9 @@ export const ConnectionModal: React.FC = ({ if (isCredential) { // Server persists option.password when savePassword !== false. await onSaveCredential?.({ - name: name.trim() || `${form.host}/${form.database}`, + name: + name.trim() || + (isFileDialect ? form.database || selDialect : `${form.host}/${form.database}`), dialect: selDialect, schema: form.schema, option, @@ -234,6 +245,17 @@ export const ConnectionModal: React.FC = ({ const labelCls = 'text-[10px] uppercase font-bold text-slate-400 tracking-wider'; return createPortal( + <> + {browsing && ( + setBrowsing(false)} + onSelect={(path) => { + updateField('database', path); + setBrowsing(false); + }} + /> + )}
e.stopPropagation()}>
@@ -341,34 +363,68 @@ export const ConnectionModal: React.FC = ({ ) )} -
-
- - updateField('host', e.target.value)} className={inputCls} /> -
-
- - updateField('port', Number(e.target.value))} className={inputCls} /> + {!isFileDialect && ( +
+
+ + updateField('host', e.target.value)} className={inputCls} /> +
+
+ + updateField('port', Number(e.target.value))} className={inputCls} /> +
-
- -
- - updateField('database', e.target.value)} className={inputCls} /> -
+ )} -
+ {isFileDialect ? (
- - updateField('username', e.target.value)} className={inputCls} /> + +
+ updateField('database', e.target.value)} + className={`${inputCls} mt-0 flex-1`} + /> + +
+

+ Path on the machine running Fox Schema. A file that does not exist yet is + created on first connect + {selDialect === 'sqlite' ? '; `:memory:` opens a scratch database' : ''}. +

+ ) : (
- - updateField('password', e.target.value)} className={inputCls} /> + + updateField('database', e.target.value)} className={inputCls} />
-
+ )} - {isCredential && ( + {!isFileDialect && ( +
+
+ + updateField('username', e.target.value)} className={inputCls} /> +
+
+ + updateField('password', e.target.value)} className={inputCls} /> +
+
+ )} + + {isCredential && !isFileDialect && ( )} + {!isFileDialect && (
+ )}
-
, +
+ , document.body ); }; diff --git a/apps/web/src/frontend/components/DatabaseFilePicker.tsx b/apps/web/src/frontend/components/DatabaseFilePicker.tsx new file mode 100644 index 00000000..a22c2532 --- /dev/null +++ b/apps/web/src/frontend/components/DatabaseFilePicker.tsx @@ -0,0 +1,283 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Pick a SQLite / DuckDB file from the machine running the backend. + * + * The browser's own file dialog cannot help here: it yields a `File` with a + * name and no path, and the server needs a path it can open. So this browses + * the server, listing directories and the files a database driver could open. + * + * A file that does not exist yet is a legitimate choice — SQLite creates it on + * first connect — so the current directory plus a typed name is selectable + * even when nothing matches it in the list. + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AlertTriangle, ChevronUp, Database, Folder, Home, Loader2, RefreshCw, X } from 'lucide-react'; +import { browseFiles, type FileBrowseEntry, type FileBrowseResult } from '../api/fileApi'; + +export interface DatabaseFilePickerProps { + /** Where to open. A file path opens its directory, with the name filled in. */ + initialPath?: string; + onCancel: () => void; + onSelect: (path: string) => void; +} + +function formatSize(bytes: number | undefined): string { + if (bytes == null) return ''; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; +} + +/** Join a directory and a file name without assuming the platform's separator. */ +export function joinPath(dir: string, name: string): string { + if (!name) return dir; + const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/'; + const trimmed = dir.endsWith(sep) ? dir.slice(0, -sep.length) : dir; + return `${trimmed}${sep}${name}`; +} + +/** True for `/var/db` and `C:\\data`, i.e. something to use as typed. */ +export function isAbsolutePath(value: string): boolean { + return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value); +} + +/** The file name in a path, for pre-filling the name box. */ +export function fileNameOf(path: string): string { + const parts = path.split(/[\\/]/); + return parts[parts.length - 1] ?? ''; +} + +export const DatabaseFilePicker: React.FC = ({ + initialPath, + onCancel, + onSelect, +}) => { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(() => + initialPath && !initialPath.endsWith('/') ? fileNameOf(initialPath) : '' + ); + + const load = useCallback((path?: string) => { + setLoading(true); + setError(null); + browseFiles(path) + .then(setResult) + .catch((err: unknown) => setError(err instanceof Error ? err.message : 'Failed to list directory')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(initialPath || undefined); + }, [load, initialPath]); + + // Escape closes; the picker is a modal over a modal, so a click-out would be + // ambiguous about which one it dismisses. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onCancel(); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [onCancel]); + + const open = (entry: FileBrowseEntry) => { + if (entry.kind === 'dir') { + setFileName(''); + load(entry.path); + } else { + setFileName(entry.name); + } + }; + + // People paste whole paths into a box labelled "File name". Honour it + // rather than gluing it onto the current directory and producing + // `/Users/me//var/db/app.db`. + const typed = fileName.trim(); + const typedIsDir = isAbsolutePath(typed) && /[\\/]$/.test(typed); + const chosen = !result || !typed ? '' : isAbsolutePath(typed) ? typed : joinPath(result.path, typed); + + return createPortal( +
+
+
+
+

Select database file

+

+ {result?.path ?? '…'} +

+
+ +
+ +
+ + + +
+ +
+ {loading && !result && ( +
+ + Listing… +
+ )} + {error && ( +
+ + {error} +
+ )} + {result && result.entries.length === 0 && !error && ( +

+ No sub-folders and no database files here. Use Up, or type a name below to create one. +

+ )} +
    + {result?.entries.map((entry) => ( +
  • + +
  • + ))} +
+ {result?.truncated && ( +

+ Too many entries to show — narrow down by opening a sub-folder. +

+ )} +
+ +
+ +
+ setFileName(e.target.value)} + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + // A pasted directory path navigates; anything else selects. + if (typedIsDir) { + load(typed); + setFileName(''); + } else if (chosen) { + onSelect(chosen); + } + }} + className="flex-1 rounded border border-slate-800 bg-slate-950 px-3 py-2 font-mono text-sm text-slate-200 outline-none focus:border-cyan-500" + /> + + +
+ {chosen && ( +

+ {chosen} +

+ )} +
+
+
, + document.body + ); +}; diff --git a/apps/web/src/frontend/components/SchemaBlueprint.tsx b/apps/web/src/frontend/components/SchemaBlueprint.tsx index 2699f09f..0c5f1529 100644 --- a/apps/web/src/frontend/components/SchemaBlueprint.tsx +++ b/apps/web/src/frontend/components/SchemaBlueprint.tsx @@ -215,6 +215,12 @@ export function SchemaBlueprint({ const keep = (status: string) => showUnchanged || status !== 'UNCHANGED'; const colDiffs = diff.columnDiffs.filter((c) => keep(c.status)); const indexDiffs = diff.indexDiffs.filter((i) => keep(i.status)); + // Position in the *unfiltered* list. Numbering the rendered rows instead + // would renumber them the moment "show unchanged" is off, so column 7 would + // read as 2 — a worse lie than showing nothing. Column order is the source + // table's own (compare.module builds it from the source columns first). + const positionOf = new Map(diff.columnDiffs.map((c, i) => [c.name, i + 1])); + const indexPositionOf = new Map(diff.indexDiffs.map((i, n) => [i.name, n + 1])); const fkDiffs = diff.foreignKeyDiffs.filter((f) => keep(f.status)); const trgDiffs = (diff.triggerDiffs ?? []).filter((t) => keep(t.status)); @@ -518,6 +524,11 @@ export function SchemaBlueprint({ + {!isRole && ( + + )} @@ -539,6 +550,14 @@ export function SchemaBlueprint({ return ( + {!isRole && ( + // Ordinal position, which is what "column id" means in + // the catalogs that expose one (Oracle COLUMN_ID, SQL + // Server column_id). Roles have members, not columns. + + )}
+ # + {isRole ? 'Member' : 'Column Name'} Original State Compare
+ {positionOf.get(col.name) ?? '—'} + {isRole && col.status !== 'UNCHANGED' && onToggleMember && ( @@ -680,6 +699,9 @@ export function SchemaBlueprint({ + @@ -708,6 +730,9 @@ export function SchemaBlueprint({ return ( +
+ # + Index Name Columns Constraint
+ {indexPositionOf.get(idx.name) ?? '—'} + {idx.status !== 'UNCHANGED' && onToggleIndex && ( diff --git a/apps/web/src/frontend/components/lokee-weave/GithubScriptDiff.tsx b/apps/web/src/frontend/components/lokee-weave/GithubScriptDiff.tsx index 2a3d82ae..3ccc26b4 100644 --- a/apps/web/src/frontend/components/lokee-weave/GithubScriptDiff.tsx +++ b/apps/web/src/frontend/components/lokee-weave/GithubScriptDiff.tsx @@ -4,47 +4,137 @@ * SPDX-License-Identifier: Apache-2.0 * * Unified GitHub-style diff for a Lokee object script (CREATE TABLE / routine). + * + * The inline pane is deliberately short — it sits inside a detail column next + * to everything else about the object. A view or a routine body is routinely + * longer than that, so the header carries a maximize button that reopens the + * same diff full screen. Both call sites (the object inspector and the version + * compare modal) get it from here rather than each growing its own copy. */ import React from 'react'; +import { Maximize2, X } from 'lucide-react'; import { diffLines } from '../../utils/lineDiff'; +/** One rendered diff line — shared by the inline pane and the maximized one. */ +function DiffLine({ line }: { line: { type: string; text: string } }): React.ReactElement { + const cls = + line.type === 'added' + ? 'bg-emerald-500/15 text-emerald-200' + : line.type === 'removed' + ? 'bg-rose-500/15 text-rose-300' + : 'text-slate-400'; + const mark = line.type === 'added' ? '+' : line.type === 'removed' ? '−' : ' '; + return ( +
+ {mark} + {line.text || ' '} +
+ ); +} + export function GithubScriptDiff({ original, modified, + title = 'Script', }: { original: string; modified: string; + /** Shown in the header and as the maximized dialog's heading. */ + title?: string; }): React.ReactElement { + const [maximized, setMaximized] = React.useState(false); const lines = diffLines(original, modified); const added = lines.filter((l) => l.type === 'added').length; const removed = lines.filter((l) => l.type === 'removed').length; + + // Escape closes it, like every other dialog here. + React.useEffect(() => { + if (!maximized) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + setMaximized(false); + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [maximized]); + + const counts = ( + + +{added} + / + −{removed} + + ); + return ( -
-
- Script - - +{added} - / - −{removed} - + <> +
+
+ {title} + + {counts} + + +
+
+          {lines.map((line, i) => (
+            
+          ))}
+        
-
-        {lines.map((line, i) => {
-          const cls =
-            line.type === 'added'
-              ? 'bg-emerald-500/15 text-emerald-200'
-              : line.type === 'removed'
-                ? 'bg-rose-500/15 text-rose-300'
-                : 'text-slate-400';
-          const mark = line.type === 'added' ? '+' : line.type === 'removed' ? '−' : ' ';
-          return (
-            
- {mark} - {line.text || ' '} + + {maximized && ( +
setMaximized(false)} + > +
e.stopPropagation()} + > +
+ {title} + + {counts} + +
- ); - })} -
-
+
+              {lines.map((line, i) => (
+                
+              ))}
+            
+
+ + )} + ); } diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx index 58c0a1ec..31184668 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.test.tsx @@ -9,18 +9,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { SchemaObjectNodeData } from './graphTypes'; const inspectLokeeObject = vi.fn(); -const planLokeeRevert = vi.fn(); -const executeLokeeRevert = vi.fn(); vi.mock('../../api/lokeeApi', () => ({ inspectLokeeObject: (...args: unknown[]) => inspectLokeeObject(...args), - planLokeeRevert: (...args: unknown[]) => planLokeeRevert(...args), - executeLokeeRevert: (...args: unknown[]) => executeLokeeRevert(...args), })); -vi.mock('../../store/toastStore', () => ({ toast: vi.fn() })); -vi.mock('../../lib/sessionPasswords', () => ({ getSessionPassword: () => undefined })); - import { LokeeObjectInspector } from './LokeeObjectInspector'; const SELECTED: SchemaObjectNodeData = { @@ -35,8 +28,6 @@ const SELECTED: SchemaObjectNodeData = { beforeEach(() => { inspectLokeeObject.mockReset(); - planLokeeRevert.mockReset(); - executeLokeeRevert.mockReset(); }); describe('LokeeObjectInspector', () => { @@ -258,8 +249,9 @@ describe('LokeeObjectInspector', () => { ); expect(screen.getByTestId('lokee-inspector-script-diff').textContent).toMatch(/varchar\(255\)/); expect(screen.getByTestId('lokee-inspector-script-diff').textContent).toMatch(/\+/); - expect(screen.getByTestId('lokee-inspector-revert-1')).toBeTruthy(); - expect(screen.queryByTestId('lokee-inspector-revert-2')).toBeNull(); + // Reverting is the compare modal's job — it can scope the revert to chosen + // objects, which a per-row button here never could. + expect(screen.queryByTestId('lokee-inspector-revert-1')).toBeNull(); }); it('does not show table growth on a function', async () => { @@ -366,11 +358,14 @@ describe('LokeeObjectInspector', () => { expect(aside.getAttribute('data-object-key')).toBe('table:CUSTOMERS'); }); - it('plans a revert when a prior version is selected', async () => { + it('folds the versions that left the object alone, and opens them on demand', async () => { + // The case the roadmap exists for: a long history in which this table moved + // twice. Printing fifteen rows to show two changes is what made it + // unreadable. inspectLokeeObject.mockResolvedValue({ blueprint: { focusKey: 'table:CUSTOMER', - container: null, + container: { key: 'table:CUSTOMER', type: 'table', name: 'customer', hash: 'h1', body: {} }, object: null, columns: [], indexes: [], @@ -384,63 +379,206 @@ describe('LokeeObjectInspector', () => { versionId: 'v1', versionNumber: 1, createdAt: '2026-08-01T00:00:00.000Z', - columns: 2, + columns: 3, indexes: 0, foreignKeys: 0, triggers: 0, - objects: 3, + objects: 4, + changed: true, }, { versionId: 'v2', versionNumber: 2, - createdAt: '2026-08-12T00:00:00.000Z', + createdAt: '2026-08-02T00:00:00.000Z', columns: 3, - indexes: 1, + indexes: 0, foreignKeys: 0, - triggers: 1, - objects: 6, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v3', + versionNumber: 3, + createdAt: '2026-08-03T00:00:00.000Z', + columns: 3, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v4', + versionNumber: 4, + createdAt: '2026-08-04T00:00:00.000Z', + columns: 3, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v5', + versionNumber: 5, + createdAt: '2026-08-05T00:00:00.000Z', + columns: 3, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v6', + versionNumber: 6, + createdAt: '2026-08-06T00:00:00.000Z', + columns: 3, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v7', + versionNumber: 7, + createdAt: '2026-08-07T00:00:00.000Z', + columns: 3, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v8', + versionNumber: 8, + createdAt: '2026-08-08T00:00:00.000Z', + columns: 3, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v9', + versionNumber: 9, + createdAt: '2026-08-09T00:00:00.000Z', + columns: 3, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 4, + changed: false, + }, + { + versionId: 'v10', + versionNumber: 10, + createdAt: '2026-08-10T00:00:00.000Z', + columns: 4, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 5, + changed: true, + }, + { + versionId: 'v11', + versionNumber: 11, + createdAt: '2026-08-11T00:00:00.000Z', + columns: 4, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 5, + changed: false, + }, + { + versionId: 'v12', + versionNumber: 12, + createdAt: '2026-08-12T00:00:00.000Z', + columns: 4, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 5, + changed: false, + }, + { + versionId: 'v13', + versionNumber: 13, + createdAt: '2026-08-13T00:00:00.000Z', + columns: 4, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 5, + changed: false, + }, + { + versionId: 'v14', + versionNumber: 14, + createdAt: '2026-08-14T00:00:00.000Z', + columns: 4, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 5, + changed: false, + }, + { + versionId: 'v15', + versionNumber: 15, + createdAt: '2026-08-15T00:00:00.000Z', + columns: 4, + indexes: 0, + foreignKeys: 0, + triggers: 0, + objects: 5, + changed: false, }, ], columnMutations: [], }); - planLokeeRevert.mockResolvedValue({ - fromVersion: { id: 'v2', number: 2 }, - toVersion: { id: 'v1', number: 1 }, - alreadyAtTarget: false, - reversal: { - risk: 'lossy', - safeCount: 0, - lossyCount: 1, - blockedCount: 0, - verdicts: [ - { - key: 'column:CUSTOMER.PHONE', - risk: 'lossy', - summary: 'CUSTOMER.PHONE: dropped by the revert', - dataLoss: 'every value in this column is destroyed', - }, - ], - }, - statements: ['ALTER TABLE customer DROP COLUMN phone'], - }); const onSelectVersion = vi.fn(); render( undefined} onSelectVersion={onSelectVersion} /> ); - await waitFor(() => expect(screen.getByTestId('lokee-inspector-revert-1')).toBeTruthy()); + + await waitFor(() => expect(screen.getByTestId('lokee-inspector-growth')).toBeTruthy()); + // Shown: the two versions that changed it, plus the head. + expect(screen.getByTestId('lokee-inspector-version-1')).toBeTruthy(); + expect(screen.getByTestId('lokee-inspector-version-10')).toBeTruthy(); + expect(screen.getByTestId('lokee-inspector-version-15')).toBeTruthy(); + // Folded: everything in between, and the count says how much. + expect(screen.queryByTestId('lokee-inspector-version-5')).toBeNull(); + expect(screen.getByTestId('lokee-roadmap-gap-2-9').textContent).toContain('8 versions'); + expect(screen.getByTestId('lokee-roadmap-gap-11-14')).toBeTruthy(); + + // Growth is stated as a delta, measured against the real previous version + // rather than the previous visible row. + expect(screen.getByTestId('lokee-inspector-version-10').textContent).toContain('+1'); + + // One gap opens without disturbing the other. + fireEvent.click(screen.getByTestId('lokee-roadmap-gap-2-9')); + await waitFor(() => expect(screen.getByTestId('lokee-inspector-version-5')).toBeTruthy()); + expect(screen.getByTestId('lokee-roadmap-gap-11-14')).toBeTruthy(); + + // And the whole history is one click away. + fireEvent.click(screen.getByTestId('lokee-roadmap-toggle-all')); + await waitFor(() => expect(screen.getByTestId('lokee-inspector-version-12')).toBeTruthy()); + expect(screen.queryByTestId('lokee-roadmap-gap-11-14')).toBeNull(); + fireEvent.click(screen.getByTestId('lokee-inspector-version-1')); expect(onSelectVersion).toHaveBeenCalledWith('v1'); - fireEvent.click(screen.getByTestId('lokee-inspector-revert-1')); - await waitFor(() => expect(screen.getByTestId('lokee-inspector-revert-plan')).toBeTruthy()); - expect(planLokeeRevert).toHaveBeenCalledWith('db1', 'v1'); - expect(screen.getByTestId('lokee-inspector-revert-plan').textContent).toContain('DROP COLUMN'); - expect(screen.getByTestId('lokee-revert-execute')).toBeTruthy(); }); it('closes from the header button', async () => { diff --git a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx index 258c0195..ff7ca19f 100644 --- a/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx +++ b/apps/web/src/frontend/components/lokee-weave/LokeeObjectInspector.tsx @@ -4,44 +4,32 @@ * SPDX-License-Identifier: Apache-2.0 * * Inspector for a Lokee graph node: columns with type/constraint subtitles, - * a GitHub-style CREATE script diff, growth, and revert-to-version. + * a GitHub-style CREATE script diff, and a roadmap of the versions that moved + * the object. Reverting lives in the version compare modal, which can scope it + * to chosen objects; this panel is for reading. * Indexes are stored but not a first-class inspector surface. */ import React, { useEffect, useState } from 'react'; import { isLokeeTableLikeType, lokeeColumnChangeSubtitle, lokeeTypeLabel } from '@foxschema/sql'; -import { Loader2, RotateCcw, X } from 'lucide-react'; +import { ChevronsUpDown, Loader2, X } from 'lucide-react'; import { - executeLokeeRevert, inspectLokeeObject, - planLokeeRevert, type LokeeHistoryEvent, type LokeeInspectResult, - type LokeeRevertPlan, type LokeeStoredObject, } from '../../api/lokeeApi'; -import { getSessionPassword } from '../../lib/sessionPasswords'; -import { objectStyle, riskStyle } from '../../lib/lokeeColors'; +import { objectStyle } from '../../lib/lokeeColors'; import { SchemaBlueprint } from '../SchemaBlueprint'; -import { toast } from '../../store/toastStore'; import { shortHash, type SchemaObjectNodeData } from './graphTypes'; import { GithubScriptDiff } from './GithubScriptDiff'; +import { buildRoadmapRows, hiddenVersionCount } from './roadmap'; import { SQL_ICON_STROKE } from '../sql-editor/sqlIconStyle'; export interface LokeeObjectInspectorProps { databaseId: string; selected: SchemaObjectNodeData; onClose: () => void; - captureConnectionId?: string; onSelectVersion?: (versionId: string) => void; - onReverted?: () => void; -} - -interface RevertUi { - versionId: string; - plan: LokeeRevertPlan | null; - busy: boolean; - error: string | null; - confirmLossy: boolean; } function typeLabel(body: Record | undefined): string { @@ -84,143 +72,29 @@ function HistoryEvent({ point }: { point: LokeeHistoryEvent }): React.ReactEleme )} {lines &&
{lines}
} - {point.reused && ( -
Reused hash — stored once (pointer)
- )} ); } -function RevertCard({ - connectionId, - revert, - onConfirmLossy, - onExecute, - onCancel, -}: { - connectionId?: string; - revert: RevertUi; - onConfirmLossy: (checked: boolean) => void; - onExecute: () => void; - onCancel: () => void; -}): React.ReactElement { - const plan = revert.plan; - const blocked = plan?.reversal.risk === 'blocked'; - const lossy = plan?.reversal.risk === 'lossy'; - const canRun = - Boolean(plan) && - !revert.busy && - !blocked && - Boolean(connectionId) && - (!lossy || revert.confirmLossy) && - !plan?.alreadyAtTarget; - - return ( -
-

Revert schema

- {revert.busy && !plan && ( -
- - Planning reverse DDL… -
- )} - {revert.error &&

{revert.error}

} - {plan && ( - <> -

- Apply reverse DDL so the live schema matches v{plan.toVersion.number}, then record a new - version. -

- {plan.alreadyAtTarget ? ( -

Already at this version.

- ) : ( - <> -
- {riskStyle(plan.reversal.risk).label} - {plan.reversal.lossyCount > 0 ? ` · ${plan.reversal.lossyCount} lossy` : ''} - {plan.reversal.blockedCount > 0 ? ` · ${plan.reversal.blockedCount} blocked` : ''} -
-
    - {plan.reversal.verdicts.slice(0, 12).map((v) => ( -
  • - {v.summary} - {v.dataLoss ? ` — ${v.dataLoss}` : ''} -
  • - ))} -
- {plan.statements.length > 0 && ( -
-                  {plan.statements.join('\n')}
-                
- )} - {lossy && ( - - )} -
- - -
- {!connectionId && ( -

- Pick a credential in History to apply the revert. -

- )} - - )} - - )} -
- ); -} - export function LokeeObjectInspector({ databaseId, selected, onClose, - captureConnectionId, onSelectVersion, - onReverted, }: LokeeObjectInspectorProps): React.ReactElement { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [data, setData] = useState(null); - const [revert, setRevert] = useState(null); + // Roadmap view state. `showAllVersions` opens the flat stretches back up; + // `expandedGaps` opens just one of them. + const [showAllVersions, setShowAllVersions] = useState(false); + const [expandedGaps, setExpandedGaps] = useState>(() => new Set()); useEffect(() => { let cancelled = false; setLoading(true); setError(null); - setRevert(null); + setExpandedGaps(new Set()); // Drop the previous object's payload: this component is not remounted when // the selection changes, so keeping it would render the old blueprint, // source, and growth under the new object's name until the fetch lands. @@ -251,48 +125,13 @@ export function LokeeObjectInspector({ const growthKind = data?.blueprint.container?.type ?? selected.objectType; const showGrowth = isLokeeTableLikeType(growthKind) && (data?.growth.length ?? 0) > 0; const headVersionId = data?.growth.at(-1)?.versionId ?? null; - - const openRevert = async (versionId: string) => { - setRevert({ versionId, plan: null, busy: true, error: null, confirmLossy: false }); - try { - const plan = await planLokeeRevert(databaseId, versionId); - setRevert((prev) => (prev?.versionId === versionId ? { ...prev, plan, busy: false } : prev)); - } catch (err: unknown) { - setRevert((prev) => - prev?.versionId === versionId - ? { ...prev, busy: false, error: err instanceof Error ? err.message : 'Failed to plan revert' } - : prev - ); - } - }; - - const runRevert = async () => { - if (!revert?.plan || !captureConnectionId) return; - setRevert((prev) => (prev ? { ...prev, busy: true, error: null } : prev)); - try { - const result = await executeLokeeRevert(databaseId, { - toVersionId: revert.versionId, - connectionId: captureConnectionId, - password: getSessionPassword(captureConnectionId) || undefined, - confirmLossy: revert.plan.reversal.risk === 'lossy' ? revert.confirmLossy : undefined, - }); - toast({ - tone: 'success', - title: result.alreadyAtTarget - ? `Already at v${result.toVersion.number}` - : `Reverted to v${result.toVersion.number}`, - body: result.capture?.changed - ? `Recorded v${result.capture.versionNumber} · ${result.capture.changeCount} object change(s)` - : 'Live schema matches that version.', - }); - setRevert(null); - onReverted?.(); - } catch (err: unknown) { - setRevert((prev) => - prev ? { ...prev, busy: false, error: err instanceof Error ? err.message : 'Revert failed' } : prev - ); - } - }; + const roadmapRows = buildRoadmapRows(data?.growth ?? [], { + headVersionId, + selectedVersionId: selected.versionId, + expandedGaps, + showAll: showAllVersions, + }); + const hiddenVersions = hiddenVersionCount(roadmapRows); return (