Files
windmill/frontend/src/lib/components/DBTable.svelte
T
Diego ImbertandClaude Opus 5.5 4fd7d62bd0 feat: rework the db manager: native grid, tabs, sql editor, joined columns (#11340)
* feat: replace ag-grid in the db manager table viewer with a native grid

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add user-managed data, diagram and sql editor tabs to the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add schema autocomplete to the db manager sql editor and polish its layout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: add joined foreign key columns and draggable tabs to the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: move db manager tabs on drop instead of mid-drag

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: open followed foreign keys in a new db manager tab

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: scroll large tables, drop stale joins and return to the last tab on close

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: tolerate the db manager tabs going away while switching data table

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep saved joined columns while metadata loads, test joins in every dialect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: put the db manager grid on the input surface in dark mode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: address db manager review nits on joins, boolean keys and sql quoting

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the dark border color on the left pinned column edge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: tone down the db manager tree menu icons

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write json and jsonb values from the db manager through a text cast

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: dim unrelated diagram tables less on hover

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: match json columns as text when deleting a db manager row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: compare postgres json columns as text in exact db manager filters

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: pick the columns the db manager grid shows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: toggle every db manager column from one checkbox

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: word joined columns as a view in the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep data table roles and access visible when unavailable, with the reason

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: keep data table and instance roles visible in settings when unavailable

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: hide a db manager column from its header menu

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: format db manager columns with a unit, significant digits and color rules

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop the before/after hints from the db manager unit picker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write the euro after the amount and keep units off non-numeric values

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: color rule presets, bold and italic, layered and reorderable rules in the db manager

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: decimals, thousands separator, compact notation and alignment in db manager column formats

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: compact db manager format controls, a notation toggle group and a reset button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: db manager format and filter nits

Keep the value's scale when decimals are auto, read boolean color-rule conditions as
booleans, let a rule's text color reach foreign-key links, close the formatter when the
columns picker opens, and filter BigQuery complex columns through TO_JSON_STRING.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 15:58:25 +02:00

1447 lines
50 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" module>
export type DbRowFilter = { column: string; value: unknown }
export type DbForeignKeyTarget = { table: string; column: string; value: unknown }
type Block =
| { status: 'loading' }
| { status: 'loaded'; rows: Record<string, unknown>[] }
| { status: 'error' }
const ROW_HEIGHT = 34
const HEADER_HEIGHT = 30
const BLOCK_SIZE = 100
const MAX_BLOCKS = 20
const OVERSCAN = 10
const MIN_COL_WIDTH = 60
const DEFAULT_COL_WIDTH = 150
const MAX_AUTO_COL_WIDTH = 400
// Header padding plus its sort and key icons.
const HEADER_CHROME = 48
const CELL_PADDING = 18
// A double click starts with a click: following a foreign key waits this long
// so the double click can edit the cell instead.
const FK_CLICK_DELAY = 250
function formatCell(value: unknown): string {
if (value === null || value === undefined) return ''
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
const hasForeignKeyValue = (value: unknown) =>
value !== null && value !== undefined && value !== ''
</script>
<script lang="ts">
import { sendUserToast } from '$lib/toast'
import { Button } from './common'
import {
ArrowDown,
ArrowUp,
ClipboardCopy,
Columns3,
EyeOff,
Download,
KeyRound,
Link,
Merge,
Paintbrush,
Pencil,
Plus,
Pin,
PinOff,
ArrowLeftToLine,
ArrowRightToLine,
Trash2
} from 'lucide-svelte'
import InsertRowDrawerButton from './apps/components/display/InsertRowDrawerButton.svelte'
import type { IDbTableOps } from './dbOps'
import type { TableEditorForeignKey } from './apps/components/display/dbtable/tableEditor'
import type { ColumnDef } from './apps/components/display/dbtable/utils'
import { untrack } from 'svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import FilterSearchbar from './FilterSearchbar.svelte'
import {
buildDbTableFilterSchema,
dbColumnKind,
exactColumnFilterValue,
FREE_TEXT_KEY,
columnFiltersOf,
unescapeFreeText,
renderDbTableFilters
} from './dbTableFilters'
import ContextMenu, { type ContextMenuItem } from './common/contextmenu/ContextMenu.svelte'
import GenericDropdown from './select/GenericDropdown.svelte'
import DropdownV2 from './DropdownV2.svelte'
import Popover from './meltComponents/Popover.svelte'
import Select from './select/Select.svelte'
import Checkbox from './common/checkbox/Checkbox.svelte'
import TextInput from './text_input/TextInput.svelte'
import { copyToClipboard, download } from '$lib/utils'
import { readDbTableLayout, saveDbTableLayout, type DbTableLayout } from './dbTableLayoutStorage'
import { convertJsonToCsv } from './table/tableUtils'
import { twMerge } from 'tailwind-merge'
import { deepEqual } from 'fast-equals'
import { joinAlias, joinedColumnDef, type DbJoinTarget, type DbTableJoin } from './dbTableJoins'
import { cellStyle, formatValue, isEmptyFormat, type ColumnFormat } from './dbTableFormat'
import DbColumnFormatEditor from './DbColumnFormatEditor.svelte'
const operatingWorkspace = useOperatingWorkspace()
type Props = {
dbTableOps: IDbTableOps
/** Foreign keys of the displayed table; their values link to the referenced row. */
foreignKeys?: TableEditorForeignKey[]
onGoToRow?: (target: DbForeignKeyTarget) => void
/** Replaces the search bar's filters with `column = value` whenever a new
* one is passed, then `onRowFilterApplied` is called so it applies once. */
rowFilter?: DbRowFilter
onRowFilterApplied?: () => void
/** localStorage key under which the columns the user resized or pinned are kept. */
layoutStorageKey?: string
/** Tables this one references by foreign key, whose columns can be shown beside its own.
* Undefined while they are not known yet. */
joinTargets?: DbJoinTarget[]
/** Offered from the add-column button: adding a column to the table itself. */
onNewColumn?: () => void
}
let {
dbTableOps,
foreignKeys,
onGoToRow,
rowFilter,
onRowFilterApplied,
layoutStorageKey,
joinTargets,
onNewColumn
}: Props = $props()
const storedLayout = untrack(() =>
layoutStorageKey ? readDbTableLayout(layoutStorageKey) : ({} as DbTableLayout)
)
let joins: DbTableJoin[] = $state(storedLayout.joins ?? [])
let canAddColumn = $derived(!!onNewColumn || !!joinTargets?.length)
let columns: ColumnDef[] = $derived([
...(dbTableOps.colDefs ?? []).filter((c) => c?.field && !c.ignored && !c.hide),
...joins.map(joinedColumnDef)
])
// ── Hidden columns ───────────────────────────────────────────────────────
// Only left out of the grid: filters and reads still cover them, and they keep their width
// and pin for when they are shown again.
let hidden: string[] = $state([...(storedLayout.hidden ?? [])])
let columnsPickerOpen = $state(false)
let visibleColumns = $derived(columns.filter((c) => !hidden.includes(c.field)))
function setHidden(field: string, hide: boolean) {
hidden = hide ? [...hidden, field] : hidden.filter((f) => f !== field)
saveLayout()
}
/** The shown columns, as the button that picks them names them. */
let columnsLabel = $derived(
visibleColumns.length > 2
? `${visibleColumns
.slice(0, 2)
.map((c) => c.field)
.join(', ')}, +${visibleColumns.length - 2}`
: visibleColumns.map((c) => c.field).join(', ') || 'No columns'
)
let joinedAliases = $derived(new Set(joins.map(joinAlias)))
/** A row as the table has it: joined columns belong to another table, so an update or
* delete must not match on them. */
function ownValues(row: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(row).filter(([k]) => !joinedAliases.has(k)))
}
// ── Filters ──────────────────────────────────────────────────────────────
let filterSchema = $derived(buildDbTableFilterSchema(columns))
let filters: Record<string, any> = $state({})
let quicksearch = $derived(unescapeFreeText(String(filters[FREE_TEXT_KEY] ?? '')))
let filterWhere = $derived(
renderDbTableFilters(filters, filterSchema, columns, dbTableOps.dbType)
)
let columnFilters = $derived(columnFiltersOf(filters, filterSchema))
let hasFilters = $derived(!!quicksearch || !!filterWhere)
$effect(() => {
const f = rowFilter
if (!f) return
untrack(() => {
const key = filterSchema.keyOfColumn[f.column]
if (key) {
const datatype = columns.find((c) => c.field === f.column)?.datatype
filters = { [key]: exactColumnFilterValue(datatype, f.value) }
}
onRowFilterApplied?.()
})
})
// ── Foreign keys ─────────────────────────────────────────────────────────
/** Single-column foreign keys only: a composite key has no one cell value to follow. */
let fkByColumn = $derived.by(() => {
const out: Record<string, { table: string; column: string }> = {}
if (!onGoToRow) return out
for (const fk of foreignKeys ?? []) {
if (fk.columns.length !== 1) continue
const { sourceColumn, targetColumn } = fk.columns[0]
if (!sourceColumn || !targetColumn || !fk.targetTable) continue
out[sourceColumn] = { table: fk.targetTable, column: targetColumn }
}
return out
})
let fkClickTimer: ReturnType<typeof setTimeout> | undefined
function followForeignKey(column: string, value: unknown) {
const fk = fkByColumn[column]
if (!fk) return
clearTimeout(fkClickTimer)
const go = () => onGoToRow?.({ table: fk.table, column: fk.column, value })
if (dbTableOps.onUpdate) fkClickTimer = setTimeout(go, FK_CLICK_DELAY)
else go()
}
// ── Sorting ──────────────────────────────────────────────────────────────
let sort: { column: string; desc: boolean } | undefined = $state()
function toggleSort(column: string) {
if (sort?.column !== column) sort = { column, desc: false }
else if (!sort.desc) sort = { column, desc: true }
else sort = undefined
}
// ── Rows (infinite scroll) ───────────────────────────────────────────────
let blocks: Record<number, Block> = $state.raw({})
/** Bumped on every reload so reads of an older query are dropped. */
let generation = $state(0)
/** Known once a block comes back short. */
let endRow: number | undefined = $state()
let rowCount: number | undefined = $state()
let refreshCount = $state(0)
export const refresh = () => (refreshCount += 1)
let scrollEl: HTMLDivElement | undefined = $state()
let scrollTop = $state(0)
let viewportHeight = $state(0)
let viewportWidth = $state(0)
let gridOuterWidth = $state(0)
let gridOuterHeight = $state(0)
// Parents rebuild `dbTableOps` on unrelated updates: compare what it reads.
let prevQuery: Record<string, unknown> | undefined
$effect(() => {
const query = {
table: dbTableOps.tableKey,
colDefs: dbTableOps.colDefs,
quicksearch,
filterWhere,
sort,
joins,
workspace: $operatingWorkspace,
refreshCount
}
untrack(() => {
if (deepEqual(query, prevQuery)) return
// A refresh keeps the scroll position; a new query starts from the top.
const onlyRefreshed =
prevQuery && deepEqual({ ...query, refreshCount: 0 }, { ...prevQuery, refreshCount: 0 })
prevQuery = query
if (!onlyRefreshed && scrollEl) scrollEl.scrollTop = 0
reload()
})
})
function reload() {
generation += 1
blocks = {}
endRow = undefined
rowCount = undefined
editing = undefined
if (!$operatingWorkspace) return
const gen = generation
dbTableOps
.getCount({ quicksearch, whereClause: filterWhere, columnFilters, joins })
.then((count) => gen === generation && (rowCount = count))
.catch(() => {})
}
let totalRows = $derived.by(() => {
if (endRow !== undefined) return endRow
let loadedEnd = 0
for (const [i, block] of Object.entries(blocks)) {
if (block.status === 'loaded')
loadedEnd = Math.max(loadedEnd, Number(i) * BLOCK_SIZE + block.rows.length)
}
// Unknown size: leave room for the next block so scrolling reaches it.
return rowCount !== undefined ? Math.max(rowCount, loadedEnd) : loadedEnd + BLOCK_SIZE
})
// Browsers cap an element's height (about 33M px in Chrome, 18M in Firefox), which at
// ROW_HEIGHT is under a million rows. Past this cap the scroll range stands in for the rows'
// full height, scaled: scrollTop maps to an offset into them, and rows are drawn from there.
const MAX_SCROLL_HEIGHT = 10_000_000
let bodyHeight = $derived(Math.max(0, viewportHeight - HEADER_HEIGHT))
let rowsHeight = $derived(totalRows * ROW_HEIGHT)
let scrollBodyHeight = $derived(Math.min(rowsHeight, MAX_SCROLL_HEIGHT))
let scrollScale = $derived(
rowsHeight <= MAX_SCROLL_HEIGHT || scrollBodyHeight <= bodyHeight
? 1
: (rowsHeight - bodyHeight) / (scrollBodyHeight - bodyHeight)
)
let rowsOffset = $derived(scrollTop * scrollScale)
let firstVisible = $derived(Math.floor(rowsOffset / ROW_HEIGHT))
let lastVisible = $derived(Math.min(totalRows, Math.ceil((rowsOffset + bodyHeight) / ROW_HEIGHT)))
let renderStart = $derived(Math.max(0, firstVisible - OVERSCAN))
let renderEnd = $derived(Math.min(totalRows, lastVisible + OVERSCAN))
let renderedIndices = $derived(
Array.from({ length: Math.max(0, renderEnd - renderStart) }, (_, i) => renderStart + i)
)
$effect(() => {
generation
if (!$operatingWorkspace || renderEnd <= renderStart) return
const from = Math.floor(renderStart / BLOCK_SIZE)
const to = Math.floor((renderEnd - 1) / BLOCK_SIZE)
untrack(() => {
for (let b = from; b <= to; b++) if (!blocks[b]) loadBlock(b)
evictBlocks((from + to) / 2)
})
})
async function loadBlock(b: number) {
const gen = generation
blocks = { ...blocks, [b]: { status: 'loading' } }
try {
const rows = (await dbTableOps.getRows({
offset: b * BLOCK_SIZE,
limit: BLOCK_SIZE,
quicksearch,
whereClause: filterWhere,
columnFilters,
order_by: sort?.column ?? columns[0]?.field ?? '',
is_desc: sort?.desc ?? false,
explicitSort: !!sort,
joins
})) as Record<string, unknown>[]
if (gen !== generation) return
blocks = { ...blocks, [b]: { status: 'loaded', rows } }
if (rows.length < BLOCK_SIZE) {
const end = b * BLOCK_SIZE + rows.length
endRow = endRow === undefined ? end : Math.min(endRow, end)
}
} catch (e) {
if (gen !== generation) return
blocks = { ...blocks, [b]: { status: 'error' } }
sendUserToast(`Error loading rows: ${(e as Error)?.message ?? e}`, true)
}
}
function evictBlocks(center: number) {
const keys = Object.keys(blocks).map(Number)
if (keys.length <= MAX_BLOCKS) return
const keep = new Set(
keys.sort((a, b) => Math.abs(a - center) - Math.abs(b - center)).slice(0, MAX_BLOCKS)
)
blocks = Object.fromEntries(Object.entries(blocks).filter(([k]) => keep.has(Number(k))))
}
function rowAt(i: number): Record<string, unknown> | undefined | 'loading' {
const block = blocks[Math.floor(i / BLOCK_SIZE)]
if (!block || block.status === 'loading') return 'loading'
if (block.status === 'error') return undefined
return block.rows[i % BLOCK_SIZE]
}
function loadedRows(): Record<string, unknown>[] {
return Object.keys(blocks)
.map(Number)
.sort((a, b) => a - b)
.flatMap((k) => {
const block = blocks[k]
return block.status === 'loaded' ? block.rows : []
})
}
// ── Stored layout ────────────────────────────────────────────────────────
// Widths are kept once settled, whether auto-sized from the content or resized by hand, so a
// table reopens as it was left rather than re-fitting to whatever rows come first.
function saveLayout() {
if (!layoutStorageKey) return
const layout: DbTableLayout = {}
const sizedWidths = Object.fromEntries(
[...sizedColumns].filter((f) => widths[f] !== undefined).map((f) => [f, widths[f]])
)
if (Object.keys(sizedWidths).length) layout.widths = sizedWidths
if (Object.keys(pinned).length) layout.pinned = $state.snapshot(pinned)
if (joins.length) layout.joins = $state.snapshot(joins)
if (hidden.length) layout.hidden = $state.snapshot(hidden)
if (Object.keys(formats).length) layout.formats = $state.snapshot(formats)
saveDbTableLayout(layoutStorageKey, layout)
}
// ── Column widths ────────────────────────────────────────────────────────
let widths: Record<string, number> = $state({ ...storedLayout.widths })
let sizedColumns = new Set<string>(Object.keys(storedLayout.widths ?? {}))
let autoSizedFor: ColumnDef[] | undefined
let measureCtx: CanvasRenderingContext2D | null | undefined
function textWidth(text: string, font: string): number {
measureCtx ??= document.createElement('canvas').getContext('2d')
if (!measureCtx) return text.length * 7
measureCtx.font = font
return measureCtx.measureText(text).width
}
function autoSize(only?: string[]) {
if (!scrollEl) return
const family = getComputedStyle(scrollEl).fontFamily
const rows = loadedRows().slice(0, 300)
const next = { ...widths }
for (const col of columns) {
if (only ? !only.includes(col.field) : sizedColumns.has(col.field)) continue
let w = textWidth(col.field, `600 12px ${family}`) + HEADER_CHROME
for (const row of rows) {
const text = formatCell(row[col.field]).slice(0, 200) || 'NULL'
w = Math.max(w, textWidth(text, `400 12px ${family}`) + CELL_PADDING)
}
next[col.field] = Math.round(Math.min(MAX_AUTO_COL_WIDTH, Math.max(MIN_COL_WIDTH, w)))
sizedColumns.add(col.field)
}
widths = next
saveLayout()
}
// Sized once per column set, from the first rows: later blocks would make
// columns jump while scrolling.
$effect(() => {
const first = blocks[0]
if (!first || first.status === 'loading' || autoSizedFor === columns) return
untrack(() => {
autoSizedFor = columns
autoSize()
})
})
let colWidth = (field: string) => widths[field] ?? DEFAULT_COL_WIDTH
const ADD_COLUMN_WIDTH = 36
// A real cell rather than an overlay, so a horizontally scrolled grid can reach it.
const HIDDEN_NOTE_WIDTH = 190
let totalWidth = $derived(
visibleColumns.reduce((acc, c) => acc + colWidth(c.field), 0) +
(canAddColumn ? ADD_COLUMN_WIDTH : 0) +
(hidden.length ? HIDDEN_NOTE_WIDTH : 0)
)
// ── Pinning ──────────────────────────────────────────────────────────────
let pinned: Record<string, 'left' | 'right'> = $state({ ...storedLayout.pinned })
// ── Joined columns ───────────────────────────────────────────────────────
// A joined column is read under its alias: one equal to a column of the table itself would
// make the read's columns ambiguous.
let ownFields = $derived(new Set((dbTableOps.colDefs ?? []).map((c) => c.field)))
function aliasTaken(join: Pick<DbTableJoin, 'sourceColumn' | 'column'>): boolean {
const alias = joinAlias(join)
return joinedAliases.has(alias) || ownFields.has(alias)
}
function addJoin(join: DbTableJoin) {
if (aliasTaken(join)) return
joins = [...joins, join]
saveLayout()
}
// A stored join outlives the foreign key it follows: once the keys are known, one that no
// longer matches (key dropped, column renamed) would fail every read of the table, and one
// whose target column changed type would filter with the old one.
$effect(() => {
const targets = joinTargets
const fields = ownFields
if (!targets) return
untrack(() => {
const next = joins.flatMap((j) => {
const target = targets.find(
(t) =>
t.sourceColumn === j.sourceColumn &&
t.targetTable === j.targetTable &&
t.targetColumn === j.targetColumn
)
const column = target?.columns.find((c) => c.field === j.column)
if (!column || fields.has(joinAlias(j))) return []
return [column.datatype === j.datatype ? j : { ...j, datatype: column.datatype }]
})
if (next.length === joins.length && next.every((j, i) => j === joins[i])) return
joins = next
saveLayout()
})
})
function joinedColumnTitle(alias: string): string {
const join = joins.find((j) => joinAlias(j) === alias)
if (!join) return alias
return `${join.column} of ${join.targetTable}, joined on ${join.sourceColumn}. Only shown in this view.`
}
function removeJoin(alias: string) {
joins = joins.filter((j) => joinAlias(j) !== alias)
setPin(alias, undefined)
}
let displayColumns = $derived([
...visibleColumns.filter((c) => pinned[c.field] === 'left'),
...visibleColumns.filter((c) => !pinned[c.field]),
...visibleColumns.filter((c) => pinned[c.field] === 'right')
])
let firstRightPinned = $derived(displayColumns.find((c) => pinned[c.field] === 'right')?.field)
let lastLeftPinned = $derived(displayColumns.findLast((c) => pinned[c.field] === 'left')?.field)
/** Sticky offset of each pinned column, from the edge it is pinned to. */
let pinOffsets = $derived.by(() => {
const out: Record<string, number> = {}
let acc = 0
for (const c of displayColumns) {
if (pinned[c.field] !== 'left') continue
out[c.field] = acc
acc += colWidth(c.field)
}
acc = 0
for (const c of [...displayColumns].reverse()) {
if (pinned[c.field] !== 'right') continue
out[c.field] = acc
acc += colWidth(c.field)
}
return out
})
let pinnedWidth = (side: 'left' | 'right') =>
visibleColumns.reduce((acc, c) => (pinned[c.field] === side ? acc + colWidth(c.field) : acc), 0)
function setPin(column: string, side: 'left' | 'right' | undefined) {
const next = { ...pinned }
if (side) next[column] = side
else delete next[column]
pinned = next
saveLayout()
}
function startResize(e: PointerEvent, field: string) {
e.preventDefault()
e.stopPropagation()
const handle = e.currentTarget as HTMLElement
handle.setPointerCapture(e.pointerId)
const startX = e.clientX
const startWidth = colWidth(field)
const onMove = (ev: PointerEvent) => {
sizedColumns.add(field)
widths = {
...widths,
[field]: Math.max(MIN_COL_WIDTH, Math.round(startWidth + ev.clientX - startX))
}
}
const onUp = () => {
handle.removeEventListener('pointermove', onMove)
handle.removeEventListener('pointerup', onUp)
if (colWidth(field) !== startWidth) saveLayout()
}
handle.addEventListener('pointermove', onMove)
handle.addEventListener('pointerup', onUp)
}
// ── Selection & keyboard ─────────────────────────────────────────────────
let selected: { row: number; column: string } | undefined = $state()
function cellFromEvent(e: Event): { row: number; column: string } | undefined {
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-cell-row]')
if (!el) return undefined
const cell = { row: Number(el.dataset.cellRow), column: el.dataset.cellColumn! }
const data = rowAt(cell.row)
return data && data !== 'loading' ? cell : undefined
}
function headerFromEvent(e: Event): string | undefined {
return (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-header-column]')?.dataset
.headerColumn
}
function scrollIntoView(cell: { row: number; column: string }) {
if (!scrollEl) return
const top = cell.row * ROW_HEIGHT
if (top < rowsOffset) scrollEl.scrollTop = top / scrollScale
else if (top + ROW_HEIGHT > rowsOffset + bodyHeight)
scrollEl.scrollTop = (top + ROW_HEIGHT - bodyHeight) / scrollScale
// A pinned column is always in view; the others must clear the pinned ones.
if (pinned[cell.column]) return
let left = 0
for (const c of displayColumns) {
if (c.field === cell.column) break
left += colWidth(c.field)
}
const width = colWidth(cell.column)
const visibleLeft = scrollEl.scrollLeft + pinnedWidth('left')
const visibleRight = scrollEl.scrollLeft + viewportWidth - pinnedWidth('right')
if (left < visibleLeft) scrollEl.scrollLeft = left - pinnedWidth('left')
else if (left + width > visibleRight)
scrollEl.scrollLeft = left + width - viewportWidth + pinnedWidth('right')
}
function onGridKeyDown(e: KeyboardEvent) {
if (!selected || editing) return
const colIdx = displayColumns.findIndex((c) => c.field === selected!.column)
let next: { row: number; column: string } | undefined
switch (e.key) {
case 'ArrowDown':
next = { ...selected, row: Math.min(totalRows - 1, selected.row + 1) }
break
case 'ArrowUp':
next = { ...selected, row: Math.max(0, selected.row - 1) }
break
case 'ArrowRight':
next = {
...selected,
column: displayColumns[Math.min(displayColumns.length - 1, colIdx + 1)].field
}
break
case 'ArrowLeft':
next = { ...selected, column: displayColumns[Math.max(0, colIdx - 1)].field }
break
case 'Enter':
case 'F2':
startEdit(selected.row, selected.column)
break
case 'c':
if (!(e.ctrlKey || e.metaKey) || window.getSelection()?.toString()) return
copyCell(selected.row, selected.column)
break
default:
return
}
e.preventDefault()
if (next) {
selected = next
scrollIntoView(next)
}
}
function copyCell(row: number, column: string) {
const data = rowAt(row)
if (!data || data === 'loading') return
copyToClipboard(formatCell(data[column]))
}
// ── Editing ──────────────────────────────────────────────────────────────
let editing:
| { row: number; column: string; data: Record<string, unknown>; initial: string; value: string }
| undefined = $state()
let editorInput: TextInput<'input'> | undefined = $state()
let editorRect = new DOMRect()
function cellElement(row: number, column: string): HTMLElement | null | undefined {
return scrollEl?.querySelector<HTMLElement>(
`[data-cell-row="${row}"][data-cell-column="${CSS.escape(column)}"]`
)
}
function startEdit(row: number, column: string) {
clearTimeout(fkClickTimer)
if (!dbTableOps.onUpdate || joinedAliases.has(column)) return
const data = rowAt(row)
if (!data || data === 'loading') return
selected = { row, column }
const initial = formatCell(data[column])
editing = { row, column, data, initial, value: initial }
requestAnimationFrame(() => {
editorInput?.focus()
editorInput?.select()
})
}
async function commitEdit() {
const edit = editing
editing = undefined
scrollEl?.focus({ preventScroll: true })
if (!edit || edit.value === edit.initial || !$operatingWorkspace || !dbTableOps.onUpdate) return
const colDef = columns.find((c) => c.field === edit.column)
if (!colDef) return
// Shown right away; a failed update reloads the real value.
const b = Math.floor(edit.row / BLOCK_SIZE)
const block = blocks[b]
if (block?.status === 'loaded') {
const rows = [...block.rows]
rows[edit.row % BLOCK_SIZE] = { ...edit.data, [edit.column]: edit.value }
blocks = { ...blocks, [b]: { status: 'loaded', rows } }
}
try {
await dbTableOps.onUpdate({ values: ownValues(edit.data) }, colDef, edit.value)
sendUserToast('Value updated')
} catch (e) {
sendUserToast('Error updating value: ' + ((e as Error)?.message || e), true)
refresh()
}
}
function cancelEdit() {
editing = undefined
scrollEl?.focus({ preventScroll: true })
}
// ── Formats ──────────────────────────────────────────────────────────────
let formats: Record<string, ColumnFormat> = $state({ ...storedLayout.formats })
let formatting: string | undefined = $state()
let formatEl: HTMLDivElement | undefined = $state()
let columnsAnchor: HTMLDivElement | undefined = $state()
let formatRect = new DOMRect()
function setFormat(column: string, format: ColumnFormat) {
if (isEmptyFormat(format)) {
const { [column]: _, ...rest } = formats
formats = rest
} else formats = { ...formats, [column]: format }
saveLayout()
}
function openFormat(column: string) {
columnsPickerOpen = false
formatting = column
}
// Selects and popovers inside the editor render in portals of their own.
function closeFormatOnOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement | null
if (
!formatting ||
formatEl?.contains(target) ||
target?.closest('.dropdown-portal, [data-db-format-popover]')
)
return
formatting = undefined
}
// ── Context menu ─────────────────────────────────────────────────────────
let menuTarget:
| { kind: 'cell'; row: number; column: string }
| { kind: 'header'; column: string }
| undefined = $state()
// Runs in the capture phase, before the menu's own handler opens it, so the items are
// already those of what was clicked. Anywhere else, the browser keeps its own menu.
// A right-click while the menu is open would leave it where it first opened: close it,
// hold the click until the old menu is gone, then replay it.
let replayingContextMenu = false
function replayOnceMenuClosed(e: MouseEvent) {
const target = e.target as HTMLElement
const init: MouseEventInit = {
bubbles: true,
cancelable: true,
clientX: e.clientX,
clientY: e.clientY,
button: 2
}
document
.querySelector('[data-context-menu]')
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
const started = performance.now()
const replay = () => {
if (document.querySelector('[data-context-menu]') && performance.now() - started < 500) {
requestAnimationFrame(replay)
return
}
replayingContextMenu = true
target.dispatchEvent(new MouseEvent('contextmenu', init))
replayingContextMenu = false
}
requestAnimationFrame(replay)
}
function onContextMenuCapture(e: MouseEvent) {
if (!replayingContextMenu && document.querySelector('[data-context-menu]')) {
e.preventDefault()
e.stopPropagation()
replayOnceMenuClosed(e)
return
}
const cell = cellFromEvent(e)
if (cell) {
menuTarget = { kind: 'cell', ...cell }
selected = cell
return
}
selected = undefined
const column = headerFromEvent(e)
if (column) {
menuTarget = { kind: 'header', column }
return
}
menuTarget = undefined
e.stopPropagation()
}
let menuItems: ContextMenuItem[] = $derived.by(() => {
if (!menuTarget) return []
if (menuTarget.kind === 'header') {
const { column } = menuTarget
const pin = pinned[column]
const items: ContextMenuItem[] = [
{
id: 'hide',
label: 'Hide',
icon: EyeOff,
onClick: () => setHidden(column, true)
},
{
id: 'format',
label: 'Format…',
icon: Paintbrush,
onClick: () => openFormat(column)
}
]
if (pin !== 'left')
items.push({
id: 'pin-left',
label: 'Pin to left',
icon: ArrowLeftToLine,
onClick: () => setPin(column, 'left')
})
if (pin !== 'right')
items.push({
id: 'pin-right',
label: 'Pin to right',
icon: ArrowRightToLine,
onClick: () => setPin(column, 'right')
})
if (pin)
items.push({
id: 'unpin',
label: 'Unpin',
icon: PinOff,
onClick: () => setPin(column, undefined)
})
if (joinedAliases.has(column))
items.push(
{ id: 'divider', label: '', divider: true },
{
id: 'remove-join',
label: 'Remove from view',
icon: Trash2,
type: 'delete',
onClick: () => removeJoin(column)
}
)
return items
}
const { row, column } = menuTarget
const items: ContextMenuItem[] = [
{
id: 'copy-value',
label: 'Copy value',
icon: ClipboardCopy,
onClick: () => copyCell(row, column)
},
{
id: 'copy-row',
label: 'Copy row as JSON',
icon: ClipboardCopy,
onClick: () => {
const data = rowAt(row)
if (data && data !== 'loading') copyToClipboard(JSON.stringify(data, null, 2))
}
}
]
items.push({
id: 'format',
label: 'Format column…',
icon: Paintbrush,
onClick: () => openFormat(column)
})
if (dbTableOps.onUpdate && !joinedAliases.has(column)) {
items.push({
id: 'edit',
label: 'Edit value',
icon: Pencil,
onClick: () => startEdit(row, column)
})
}
if (dbTableOps.onDelete) {
items.push(
{ id: 'divider', label: '', divider: true },
{
id: 'delete',
label: 'Delete row',
icon: Trash2,
type: 'delete',
onClick: () => deleteRow(row)
}
)
}
return items
})
function deleteRow(row: number) {
const data = rowAt(row)
if (!data || data === 'loading' || !$operatingWorkspace) return
dbTableOps
.onDelete?.({ values: ownValues(data) })
.then(() => {
refresh()
sendUserToast('Row deleted')
})
.catch((e) => sendUserToast(`Error deleting row: ${e?.message ?? e}`, true))
}
// ── Add column ───────────────────────────────────────────────────────────
let joinPickerOpen = $state(false)
let pickedTarget: number | undefined = $state()
let pickedColumn: string | undefined = $state()
let pickedTargetColumns = $derived(
pickedTarget !== undefined ? (joinTargets?.[pickedTarget]?.columns ?? []) : []
)
function openJoinPicker() {
pickedTarget = joinTargets?.length === 1 ? 0 : undefined
pickedColumn = undefined
joinPickerOpen = true
}
function confirmJoin() {
const target = pickedTarget !== undefined ? joinTargets?.[pickedTarget] : undefined
const column = pickedTargetColumns.find((c) => c.field === pickedColumn)
if (!target || !column) return
addJoin({
sourceColumn: target.sourceColumn,
targetTable: target.targetTable,
targetColumn: target.targetColumn,
column: column.field,
datatype: column.datatype
})
joinPickerOpen = false
}
function downloadCsv() {
const rows = loadedRows()
if (!rows.length) return
download(`${dbTableOps.tableKey}.csv`, convertJsonToCsv(rows), 'text/csv')
}
</script>
{#snippet hiddenColumnsNote()}
{#if hidden.length}
<div
class="flex shrink-0 items-center whitespace-nowrap px-2 text-xs font-normal text-hint"
style:width="{HIDDEN_NOTE_WIDTH}px"
>
{hidden.length}
{hidden.length === 1 ? 'column' : 'columns'} hidden by filter
</div>
{/if}
{/snippet}
{#snippet addColumnHeader()}
{#if canAddColumn}
<div
class="relative flex shrink-0 items-center justify-center border-r"
style:width="{ADD_COLUMN_WIDTH}px"
>
<DropdownV2
items={[
...(onNewColumn
? [{ displayName: 'New column', icon: Plus, action: () => onNewColumn?.() }]
: []),
// Only offered when the table references another by foreign key.
...(joinTargets?.length
? [{ displayName: 'View joined column', icon: Merge, action: openJoinPicker }]
: [])
]}
btnId="db-table-add-column"
>
{#snippet buttonReplacement()}
<div
class="flex h-6 w-6 items-center justify-center rounded text-secondary hover:bg-surface-hover hover:text-primary"
title="Add column"
>
<Plus size={14} />
</div>
{/snippet}
</DropdownV2>
<!-- Opened from the menu above rather than by a trigger of its own. -->
<Popover
floatingConfig={{ strategy: 'fixed', placement: 'bottom-end' }}
bind:isOpen={joinPickerOpen}
class="absolute bottom-0 right-0 h-0 w-0 overflow-hidden"
triggerAttrs={{ tabindex: -1, 'aria-hidden': true }}
>
{#snippet trigger()}{/snippet}
{#snippet content()}
<div class="flex w-72 flex-col gap-2 p-3" data-testid="db-join-picker">
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-emphasis">View joined column</span>
<span class="text-2xs text-secondary">
Shows a column of a linked table in this view. The table itself is not changed.
</span>
</div>
<Select
items={(joinTargets ?? []).map((t, i) => ({
label: `${t.targetTable} (via ${t.sourceColumn})`,
value: i
}))}
bind:value={
() => pickedTarget,
(v) => {
pickedTarget = v
pickedColumn = undefined
}
}
placeholder="Table"
size="sm"
/>
<Select
items={pickedTargetColumns.map((c) => ({
label: c.field,
subtitle: c.datatype,
value: c.field,
disabled:
pickedTarget !== undefined &&
aliasTaken({
sourceColumn: joinTargets![pickedTarget].sourceColumn,
column: c.field
})
}))}
bind:value={pickedColumn}
placeholder="Column"
disabled={pickedTarget === undefined}
size="sm"
/>
<Button
variant="accent"
unifiedSize="sm"
disabled={pickedTarget === undefined || !pickedColumn}
onClick={confirmJoin}
>
Show
</Button>
</div>
{/snippet}
</Popover>
</div>
{/if}
{@render hiddenColumnsNote()}
{/snippet}
{#snippet addColumnCell()}
{#if canAddColumn}
<div class="shrink-0 border-r" style:width="{ADD_COLUMN_WIDTH}px"></div>
{/if}
{#if hidden.length}
<div class="shrink-0" style:width="{HIDDEN_NOTE_WIDTH}px"></div>
{/if}
{/snippet}
<svelte:window
onmousedown={(e) => {
if (editing) cancelEdit()
closeFormatOnOutsideClick(e)
}}
onkeydown={(e) => {
if (formatting && e.key === 'Escape') formatting = undefined
}}
/>
<div class="h-full relative flex flex-col">
<div class="flex justify-between items-center bg-surface-input pr-2">
<FilterSearchbar
class="grow rounded-none !border-0"
size="lg"
searchIconPosition="left"
dropdownMaxWidth={480}
dropdownOffsetY={1}
schema={filterSchema.schema}
bind:value={filters}
placeholder="Search, or filter by column..."
/>
<div class="flex shrink-0 items-center gap-2 text-xs text-secondary">
{#if totalRows > 0 && endRow !== 0}
<span class="whitespace-nowrap">
{Math.min(firstVisible + 1, totalRows)}–{Math.min(lastVisible, totalRows)}
{#if rowCount !== undefined || endRow !== undefined}
of {endRow ?? rowCount} rows
{/if}
</span>
{/if}
<Button
startIcon={{ icon: Download }}
variant="subtle"
unifiedSize="sm"
iconOnly
title="Download loaded rows as CSV"
onClick={downloadCsv}
/>
<div class="flex" bind:this={columnsAnchor}>
<Popover
floatingConfig={{ strategy: 'fixed', placement: 'bottom-end' }}
bind:isOpen={columnsPickerOpen}
>
{#snippet trigger()}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Columns3 }}
selected={hidden.length > 0}
nonCaptureEvent
title="Choose the columns to show"
btnClasses="max-w-64"
>
<span class="truncate">{hidden.length ? columnsLabel : 'Columns'}</span>
</Button>
{/snippet}
{#snippet content()}
<div class="flex w-60 flex-col py-1" data-testid="db-columns-picker">
<label
class="flex cursor-pointer items-center gap-2 border-b px-3 py-1.5 text-xs font-medium text-emphasis hover:bg-surface-hover"
>
<Checkbox
checked={hidden.length === 0}
indeterminate={hidden.length > 0 && hidden.length < columns.length}
onClick={() => {
hidden = hidden.length === 0 ? columns.map((c) => c.field) : []
saveLayout()
}}
/>
<span>All columns</span>
</label>
<div class="max-h-80 overflow-y-auto">
{#each columns as col (col.field)}
<div class="group flex items-center pr-1 hover:bg-surface-hover">
<label
class="flex min-w-0 grow cursor-pointer items-center gap-2 py-1.5 pl-3 text-xs text-primary"
>
<!-- Toggled on click: the popover cancels the click's default, so no
change event would follow. -->
<Checkbox
checked={!hidden.includes(col.field)}
onClick={() => setHidden(col.field, !hidden.includes(col.field))}
/>
<span class="truncate">{col.field}</span>
</label>
{#if formats[col.field]}
<span title="Formatted"
><Paintbrush size={12} class="mx-1 shrink-0 text-hint" /></span
>
{/if}
<DropdownV2
items={[
{
displayName: 'Format',
icon: Paintbrush,
action: () => openFormat(col.field)
}
]}
size="2xs"
class="opacity-0 group-hover:opacity-100 focus-within:opacity-100"
/>
</div>
{/each}
</div>
{#if onNewColumn}
<div class="border-t px-1 pt-1">
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: Plus }}
btnClasses="w-full justify-start"
onClick={() => {
columnsPickerOpen = false
onNewColumn?.()
}}
>
Add column
</Button>
</div>
{/if}
</div>
{/snippet}
</Popover>
</div>
{#if dbTableOps.onInsert}
<InsertRowDrawerButton
unifiedSize="sm"
columnDefs={dbTableOps.colDefs ?? []}
dbType={dbTableOps.dbType}
onInsert={(values) => {
if (!$operatingWorkspace) return
dbTableOps.onInsert?.({ values }).then(() => {
refresh()
sendUserToast('Row inserted')
})
}}
/>
{/if}
</div>
</div>
<div
class="relative flex flex-col flex-1 min-h-0 overflow-hidden bg-surface-tertiary dark:bg-surface-input border-t"
oncontextmenucapture={onContextMenuCapture}
>
<ContextMenu items={menuItems} class="flex-1 min-h-0 cursor-auto" zIndex="z-[9999]">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
bind:this={scrollEl}
bind:clientHeight={viewportHeight}
bind:clientWidth={viewportWidth}
bind:offsetHeight={gridOuterHeight}
bind:offsetWidth={gridOuterWidth}
class={twMerge(
'h-full w-full overflow-x-auto overflow-y-scroll relative outline-none text-xs text-primary',
// Wider than the app's default so it is easy to grab.
'[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar-thumb]:rounded-full'
)}
tabindex="0"
role="grid"
aria-rowcount={totalRows}
data-testid="db-table-grid"
onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
onkeydown={onGridKeyDown}
onclick={(e) => (selected = cellFromEvent(e))}
ondblclick={(e) => {
const cell = cellFromEvent(e)
if (cell) startEdit(cell.row, cell.column)
}}
>
<div
class="relative min-w-full"
style:width="{totalWidth}px"
style:height="{HEADER_HEIGHT + scrollBodyHeight}px"
>
<!-- Clipped because resize handles straddle the cell edges: a column pinned to the
right would push its handle past the grid and scroll it sideways. -->
<div
class="sticky top-0 z-10 flex overflow-x-clip bg-surface-tertiary dark:bg-surface-input border-b"
style:height="{HEADER_HEIGHT}px"
role="row"
>
{#each displayColumns as col (col.field)}
{@const sorted = sort?.column === col.field ? sort : undefined}
{@const pin = pinned[col.field]}
{#if col.field === firstRightPinned}
{@render addColumnHeader()}
<div class="flex-1"></div>
{/if}
<div
class={twMerge(
'relative shrink-0 flex items-center border-r',
// Above the other header cells, whose resize handles sit at z-10.
pin ? 'sticky z-20 bg-surface-tertiary dark:bg-surface-input' : '',
// The pinned block's edge against the scrolling columns is a pixel thicker.
col.field === lastLeftPinned ? 'border-r-2 border-border-light' : '',
col.field === firstRightPinned ? 'border-l-2' : ''
)}
style:width="{colWidth(col.field)}px"
style:left={pin === 'left' ? `${pinOffsets[col.field]}px` : undefined}
style:right={pin === 'right' ? `${pinOffsets[col.field]}px` : undefined}
role="columnheader"
aria-sort={sorted ? (sorted.desc ? 'descending' : 'ascending') : 'none'}
data-header-column={col.field}
>
<button
class="flex items-center gap-1 w-full h-full px-2 font-semibold text-emphasis text-left hover:bg-surface-hover min-w-0"
title={joinedAliases.has(col.field)
? joinedColumnTitle(col.field)
: `${col.field} (${col.datatype})`}
onclick={() => toggleSort(col.field)}
>
{#if col.isprimarykey}
<KeyRound size={12} class="shrink-0 text-secondary" />
{:else if fkByColumn[col.field]}
<Link size={12} class="shrink-0 text-secondary" />
{:else if joinedAliases.has(col.field)}
<Merge size={12} class="shrink-0 text-secondary" />
{/if}
<span class="truncate">{col.field}</span>
{#if pin}
<Pin size={11} class="shrink-0 text-hint" />
{/if}
{#if sorted}
{@const Icon = sorted.desc ? ArrowDown : ArrowUp}
<Icon size={12} class="shrink-0 text-secondary" />
{/if}
</button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute -right-1 top-0 bottom-0 w-2 z-10 cursor-col-resize hover:bg-border-selected/50"
title="Drag to resize, double click to fit"
onpointerdown={(e) => startResize(e, col.field)}
ondblclick={(e) => {
e.stopPropagation()
autoSize([col.field])
}}
></div>
</div>
{/each}
{#if !firstRightPinned}
{@render addColumnHeader()}
<div class="flex-1"></div>
{/if}
</div>
{#if endRow === 0}
<div class="sticky left-0 py-8 text-center text-hint" style:width="{viewportWidth}px">
{hasFilters ? 'No rows match the filters' : 'No rows'}
</div>
{/if}
<div
class="absolute left-0 right-0"
style:top="{HEADER_HEIGHT + renderStart * ROW_HEIGHT - rowsOffset + scrollTop}px"
>
{#each renderedIndices as i (i)}
{@const data = rowAt(i)}
<div
class="group flex border-b hover:bg-surface-hover"
style:height="{ROW_HEIGHT}px"
role="row"
aria-rowindex={i + 1}
>
{#each displayColumns as col (col.field)}
{@const isSelected = selected?.row === i && selected.column === col.field}
{@const pin = pinned[col.field]}
{@const style =
data && data !== 'loading'
? cellStyle(data[col.field], col.datatype, formats[col.field])
: undefined}
{#if col.field === firstRightPinned}
{@render addColumnCell()}
<div class="flex-1"></div>
{/if}
<div
class={twMerge(
'shrink-0 px-2 border-r truncate select-none',
dbColumnKind(col.datatype) === 'number' ? 'tabular-nums' : '',
(formats[col.field]?.align ??
(dbColumnKind(col.datatype) === 'number' ? 'right' : 'left')) === 'right'
? 'text-right'
: '',
pin ? 'sticky z-[1] bg-surface-tertiary dark:bg-surface-input' : '',
col.field === lastLeftPinned ? 'border-r-2 border-border-light' : '',
col.field === firstRightPinned ? 'border-l-2' : '',
isSelected ? 'outline outline-1 -outline-offset-1 outline-border-accent' : ''
)}
style:width="{colWidth(col.field)}px"
style:line-height="{ROW_HEIGHT - 1}px"
style:background-color={style?.bg}
style:color={style?.text}
style:font-weight={style?.bold ? 600 : undefined}
style:font-style={style?.italic ? 'italic' : undefined}
style:left={pin === 'left' ? `${pinOffsets[col.field]}px` : undefined}
style:right={pin === 'right' ? `${pinOffsets[col.field]}px` : undefined}
role="gridcell"
data-cell-row={i}
data-cell-column={col.field}
>
{#if pin}
<!-- The opaque background hides the row's hover tint; this restores it. -->
<div class="pointer-events-none absolute inset-0 group-hover:bg-surface-hover"
></div>
{/if}
{#if data === 'loading'}
<div class="inline-block h-2 w-2/3 rounded bg-surface-secondary animate-pulse"
></div>
{:else if data}
{@const value = data[col.field]}
{@const format = formats[col.field]}
{@const shown = formatValue(value, format) ?? formatCell(value)}
{#if value === null || value === undefined}
<span class="italic text-hint">NULL</span>
{:else if fkByColumn[col.field] && hasForeignKeyValue(value)}
{@const fk = fkByColumn[col.field]}
<button
class={[
'hover:underline underline-offset-2 max-w-full truncate',
// A rule's text color replaces the link color.
!style?.text && 'text-accent'
]}
title="Go to {fk.table} where {fk.column} = {formatCell(value)}"
data-testid="db-fk-link"
onclick={() => followForeignKey(col.field, value)}
>
{shown}
</button>
{:else}
{shown}
{/if}
{/if}
</div>
{/each}
{#if !firstRightPinned}
{@render addColumnCell()}
<div class="flex-1"></div>
{/if}
</div>
{/each}
</div>
</div>
</div>
</ContextMenu>
<!-- Lines between the rows and the scrollbars, drawn over the grid: scrollbar styling
cannot carry a border in every browser. None with overlay scrollbars, which take no room. -->
{#if gridOuterWidth - viewportWidth > 0}
<div
class="pointer-events-none absolute top-0 z-20 w-px bg-border-light"
style:right="{gridOuterWidth - viewportWidth}px"
style:height="{viewportHeight}px"
></div>
{/if}
{#if gridOuterHeight - viewportHeight > 0}
<div
class="pointer-events-none absolute left-0 z-20 h-px bg-border-light"
style:top="{viewportHeight}px"
style:width="{viewportWidth}px"
></div>
{/if}
</div>
</div>
<GenericDropdown
open={!!editing}
getInputRect={() => {
if (editing) {
const el = cellElement(editing.row, editing.column)
if (el) editorRect = el.getBoundingClientRect()
}
return editorRect
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="p-2 flex flex-col gap-1 min-w-72" onmousedown={(e) => e.stopPropagation()}>
{#if editing}
<TextInput
bind:this={editorInput}
bind:value={editing.value}
size="sm"
inputProps={{
onkeydown: (e) => {
if (e.key === 'Enter') {
e.preventDefault()
commitEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
cancelEdit()
}
}
}}
/>
<div class="text-2xs text-hint">Enter to save, Esc to cancel</div>
{/if}
</div>
</GenericDropdown>
<GenericDropdown
open={!!formatting}
maxHeight={640}
portalName="db-format-portal"
getInputRect={() => {
if (formatting) {
const header = scrollEl?.querySelector(`[data-header-column="${CSS.escape(formatting)}"]`)
// A hidden column has no header: its format opens from the columns button.
const el = header ?? columnsAnchor
if (el) formatRect = el.getBoundingClientRect()
}
return formatRect
}}
>
<div bind:this={formatEl}>
{#if formatting}
{@const column = formatting}
<DbColumnFormatEditor
{column}
format={formats[column]}
onChange={(f) => setFormat(column, f)}
/>
{/if}
</div>
</GenericDropdown>