Interactive SQL Visualiser (#177)

* chore: reworking plugin internals into modules

* feat: settings module is now working, added debug module, enabled new api module

* chore: restoring external API

* feat: refactoring project to use proper inversion of control

* feat: plugins can now be loaded in any order

* chore: final file migration, all typescript is in modules now

* chore: fixing dependencies of cellParser

* feat: csv and json views are only registered when they are not colliding with other plugins

* feat: fixed library behaviour on canvas

* feat: added ability to hide columns from csv files

* feat: adding global tables support (wip)

* feat: global tables full implementation

* feat: sqlite databases can now be previewed in explorer

* feat: improved explorer view

* feat: highlighting code in the copy code modal

* feat: advanced interactive schema visualiser
This commit is contained in:
Kacper Kula 2025-08-10 11:21:12 +02:00 committed by GitHub
parent 531d486d06
commit 403e9f3d0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2085 additions and 16 deletions

View file

@ -0,0 +1,5 @@
---
"sqlseal": minor
---
added interactive visualiser for SQL schema of external databases

View file

@ -72,6 +72,7 @@
"jsonpath": "^1.1.1",
"lodash": "^4.17.21",
"markdown-table-ts": "^1.0.3",
"mermaid": "11.4.0",
"ohm-js": "^17.1.0",
"papaparse": "^5.5.3",
"sql-parser-cst": "^0.33.1",

File diff suppressed because it is too large Load diff

View file

@ -47,9 +47,7 @@ export class Editor {
this.createCodeblockProcessor(this.codeblockElement, this.query);
if (this.db) {
// rendering structure
const schema = this.db.getSchema()
const vis = new SchemaVisualiser(schema)
const vis = new SchemaVisualiser(this.db)
vis.show(structure)
} else {
}

View file

@ -39,6 +39,73 @@ export class MemoryDatabase {
return this.query<{ name: string, type: string }>('select name, type from pragma_table_info(@tableName)', { '@tableName': tableName })
}
getDetailedTableInfo(tableName: string) {
const result = this.query<{
name: string,
type: string,
pk: number,
dflt_value: any,
notnull: number
}>(`
SELECT name, type, pk, dflt_value, [notnull]
FROM pragma_table_info(@tableName)
`, { '@tableName': tableName })
return result.data.map(row => ({
name: row.name,
type: row.type,
isPrimaryKey: row.pk === 1,
defaultValue: row.dflt_value,
notNull: row.notnull === 1
}))
}
getForeignKeys(tableName: string) {
const result = this.query<{
id: number,
seq: number,
table: string,
from: string,
to: string,
on_update: string,
on_delete: string,
match: string
}>(`
SELECT id, seq, [table], [from], [to], on_update, on_delete, [match]
FROM pragma_foreign_key_list(@tableName)
`, { '@tableName': tableName })
return result.data.map(row => ({
id: row.id,
seq: row.seq,
referencedTable: row.table,
fromColumn: row.from,
toColumn: row.to,
onUpdate: row.on_update,
onDelete: row.on_delete,
match: row.match
}))
}
getAllTables() {
const result = this.query<{ name: string }>(`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`)
return result.data.map(row => row.name)
}
getDetailedSchema() {
const tables = this.getAllTables()
return tables.map(tableName => ({
name: tableName,
columns: this.getDetailedTableInfo(tableName),
foreignKeys: this.getForeignKeys(tableName)
}))
}
getSchema(): TableInfo[] {
const tables = this.allTables().data
return tables.map(t => {

View file

@ -1,14 +1,684 @@
import { TableInfo, TableVisualiser } from "./TableVisualiser"
import mermaid from 'mermaid'
import { MemoryDatabase } from '../database/memoryDatabase'
export interface DetailedColumnInfo {
name: string
type: string
isPrimaryKey: boolean
defaultValue: any
notNull: boolean
}
export interface ForeignKeyInfo {
id: number
seq: number
referencedTable: string
fromColumn: string
toColumn: string
onUpdate: string
onDelete: string
match: string
}
export interface DetailedTableInfo {
name: string
columns: DetailedColumnInfo[]
foreignKeys: ForeignKeyInfo[]
}
export class SchemaVisualiser {
constructor(private info: TableInfo[]) { }
private initialized = false
constructor(private database: MemoryDatabase) {
this.initializeMermaid()
}
show(container: HTMLElement) {
private initializeMermaid() {
if (this.initialized) return
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
er: {
diagramPadding: 20,
layoutDirection: 'TB',
minEntityWidth: 120,
minEntityHeight: 80,
entityPadding: 15,
stroke: '#666',
fill: '#f9f9f9',
fontSize: 12,
// Relationship line styling
relationshipLabelColor: '#333',
relationshipLabelBackground: '#fff',
relationshipLabelBorder: '#666',
// Make relationships more visible
primaryColor: '#4a90e2',
primaryTextColor: '#333',
primaryBorderColor: '#666',
lineColor: '#666',
arrowheadColor: '#666',
// Force full width usage
useMaxWidth: true
},
flowchart: {
useMaxWidth: true,
htmlLabels: true
}
})
this.initialized = true
}
async show(container: HTMLElement) {
container.empty()
const c = container.createDiv({ cls: 'sqlseal-tv-container' })
this.info
.map(i => new TableVisualiser(i))
.forEach(v => v.show(c))
try {
const schema = await this.buildSchema()
const mermaidCode = this.generateMermaidERD(schema)
// Debug: Log the generated Mermaid code
console.log('Generated Mermaid ERD Code:', mermaidCode)
console.log('Schema tables detected:', schema.map(t => ({
original: t.name,
escaped: this.escapeMermaidIdentifier(t.name)
})))
const diagramContainer = container.createDiv({
cls: 'sqlseal-mermaid-container',
attr: {
id: `mermaid-${Date.now()}`
}
})
const { svg } = await mermaid.render(`diagram-${Date.now()}`, mermaidCode)
diagramContainer.innerHTML = svg
// Ensure SVG takes full width
const svgElement = diagramContainer.querySelector('svg')
if (svgElement) {
svgElement.setAttribute('width', '100%')
svgElement.setAttribute('height', '100%')
svgElement.style.width = '100%'
svgElement.style.height = '100%'
svgElement.style.maxWidth = 'none'
// Set viewBox to preserve aspect ratio while filling container
const viewBox = svgElement.getAttribute('viewBox')
if (viewBox) {
svgElement.setAttribute('preserveAspectRatio', 'xMidYMid meet')
}
}
// Add pan and zoom functionality
this.addPanZoom(diagramContainer)
// Add interactive features
this.addInteractivity(diagramContainer, schema)
// Remove relationship summary - user requested removal
} catch (error) {
console.error('Error rendering Mermaid schema:', error)
console.error('Error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
})
// Fallback to simple schema display
this.showFallbackSchema(container, await this.buildSchema())
}
}
private async buildSchema(): Promise<DetailedTableInfo[]> {
const schema = this.database.getDetailedSchema()
return schema
}
private generateMermaidERD(schema: DetailedTableInfo[]): string {
let mermaidCode = 'erDiagram\n'
// Define entities with their attributes
for (const table of schema) {
const escapedTableName = this.escapeMermaidIdentifier(table.name)
mermaidCode += ` ${escapedTableName} {\n`
for (const column of table.columns) {
const typeAnnotation = this.getColumnTypeAnnotation(column)
const keyAnnotation = this.getKeyAnnotation(column, table.foreignKeys)
const escapedColumnName = this.escapeMermaidIdentifier(column.name)
mermaidCode += ` ${column.type} ${escapedColumnName}${keyAnnotation}\n`
}
mermaidCode += ' }\n\n'
}
// Define relationships based on foreign keys
const relationships = new Set<string>()
for (const table of schema) {
for (const fk of table.foreignKeys) {
// Create a more descriptive relationship with column information
// Escape column names in the relationship label as well
const escapedFromColumn = fk.fromColumn.includes(' ') ? `"${fk.fromColumn}"` : fk.fromColumn
const escapedToColumn = fk.toColumn.includes(' ') ? `"${fk.toColumn}"` : fk.toColumn
const relationshipLabel = `"${escapedFromColumn}${escapedToColumn}"`
// Determine cardinality based on foreign key constraints
const cardinality = this.determineCardinality(table, fk, schema)
// Escape table names for Mermaid
const escapedSourceTable = this.escapeMermaidIdentifier(fk.referencedTable)
const escapedTargetTable = this.escapeMermaidIdentifier(table.name)
const relationship = ` ${escapedSourceTable} ${cardinality} ${escapedTargetTable} : ${relationshipLabel}`
relationships.add(relationship)
}
}
// Add relationships section with clear separation
if (relationships.size > 0) {
mermaidCode += '\n %% Relationships\n'
mermaidCode += Array.from(relationships).join('\n')
mermaidCode += '\n'
}
return mermaidCode
}
private escapeMermaidIdentifier(identifier: string): string {
// Mermaid requires identifiers with spaces or special characters to be quoted
// Also handle other problematic characters
// Check if identifier contains spaces, hyphens, or other special characters
const needsQuoting = /[\s\-\.\(\)\[\]{}'"!@#$%^&*+=|\\:;,<>?/~`]/.test(identifier)
if (needsQuoting) {
// Escape any existing quotes and wrap in quotes
const escaped = identifier.replace(/"/g, '\\"')
return `"${escaped}"`
}
return identifier
}
private determineCardinality(table: DetailedTableInfo, fk: ForeignKeyInfo, schema: DetailedTableInfo[]): string {
// Check if the foreign key column is also a primary key (indicates 1:1 relationship)
const fkColumn = table.columns.find(col => col.name === fk.fromColumn)
const isOneToOne = fkColumn?.isPrimaryKey
// Check if there are unique constraints (would also indicate 1:1 or 1:0..1)
// For now, we'll use simple heuristics:
if (isOneToOne) {
// One-to-one relationship
return '||--||'
} else {
// Default to one-to-many relationship (most common in normalized databases)
// Parent (referenced table) to Child (referencing table)
return '||--o{'
}
// Future enhancements could include:
// - Detecting many-to-many through junction tables: }o--o{
// - Optional relationships with: ||--o|
// - Zero-or-one relationships with: }o--||
}
private getColumnTypeAnnotation(column: DetailedColumnInfo): string {
const annotations = []
if (column.notNull) annotations.push('NOT NULL')
if (column.defaultValue !== null) annotations.push(`DEFAULT ${column.defaultValue}`)
return annotations.length > 0 ? ` (${annotations.join(', ')})` : ''
}
private getKeyAnnotation(column: DetailedColumnInfo, foreignKeys: ForeignKeyInfo[]): string {
if (column.isPrimaryKey) return ' PK'
const fk = foreignKeys.find(fk => fk.fromColumn === column.name)
if (fk) return ' FK'
return ''
}
private addPanZoom(container: HTMLElement) {
const svg = container.querySelector('svg')
if (!svg) return
// Create zoom and pan state with object references for proper binding
const zoomRef = { value: 1 }
const panRef = { x: 0, y: 0 }
let isDragging = false
let lastMousePos = { x: 0, y: 0 }
// Set up SVG for pan/zoom
svg.style.cursor = 'grab'
svg.style.overflow = 'hidden'
// Create a group element to contain all SVG content for transformations
const existingContent = svg.innerHTML
svg.innerHTML = `<g class="pan-zoom-group">${existingContent}</g>`
const panZoomGroup = svg.querySelector('.pan-zoom-group') as SVGGElement
const updateTransform = () => {
if (panZoomGroup) {
panZoomGroup.setAttribute('transform',
`translate(${panRef.x}, ${panRef.y}) scale(${zoomRef.value})`
)
}
}
// Add zoom controls UI
this.addZoomControls(container, zoomRef, panRef, updateTransform)
// Wheel zoom
svg.addEventListener('wheel', (e) => {
e.preventDefault()
const rect = svg.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
const delta = e.deltaY > 0 ? 0.9 : 1.1
const oldZoom = zoomRef.value
const newZoom = Math.max(0.1, Math.min(5, oldZoom * delta))
if (newZoom !== oldZoom) {
// Calculate mouse position in the transformed coordinate space
const mouseWorldX = (mouseX - panRef.x) / oldZoom
const mouseWorldY = (mouseY - panRef.y) / oldZoom
// Update zoom
zoomRef.value = newZoom
// Adjust pan to keep mouse position fixed in world space
panRef.x = mouseX - mouseWorldX * newZoom
panRef.y = mouseY - mouseWorldY * newZoom
updateTransform()
this.updateZoomDisplay(container, zoomRef.value)
}
})
// Mouse drag panning
svg.addEventListener('mousedown', (e) => {
if (e.button === 0) { // Left mouse button
isDragging = true
lastMousePos = { x: e.clientX, y: e.clientY }
svg.style.cursor = 'grabbing'
e.preventDefault()
}
})
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const deltaX = e.clientX - lastMousePos.x
const deltaY = e.clientY - lastMousePos.y
panRef.x += deltaX
panRef.y += deltaY
lastMousePos = { x: e.clientX, y: e.clientY }
updateTransform()
e.preventDefault()
}
const handleMouseUp = () => {
if (isDragging) {
isDragging = false
svg.style.cursor = 'grab'
}
}
// Attach global mouse events for smooth dragging
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
// Touch support for mobile
let lastTouchPos: { x: number, y: number } | null = null
let initialTouchDistance: number | null = null
let initialZoom = zoomRef.value
svg.addEventListener('touchstart', (e) => {
e.preventDefault()
if (e.touches.length === 1) {
// Single touch - panning
const touch = e.touches[0]
lastTouchPos = { x: touch.clientX, y: touch.clientY }
} else if (e.touches.length === 2) {
// Two finger pinch - zooming
const touch1 = e.touches[0]
const touch2 = e.touches[1]
initialTouchDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
)
initialZoom = zoomRef.value
lastTouchPos = null
}
})
svg.addEventListener('touchmove', (e) => {
e.preventDefault()
if (e.touches.length === 1 && lastTouchPos) {
// Single touch panning
const touch = e.touches[0]
const deltaX = touch.clientX - lastTouchPos.x
const deltaY = touch.clientY - lastTouchPos.y
panRef.x += deltaX
panRef.y += deltaY
lastTouchPos = { x: touch.clientX, y: touch.clientY }
updateTransform()
} else if (e.touches.length === 2 && initialTouchDistance) {
// Two finger pinch zoom
const touch1 = e.touches[0]
const touch2 = e.touches[1]
const currentDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
)
const scale = currentDistance / initialTouchDistance
zoomRef.value = Math.max(0.1, Math.min(5, initialZoom * scale))
updateTransform()
this.updateZoomDisplay(container, zoomRef.value)
}
})
svg.addEventListener('touchend', () => {
lastTouchPos = null
initialTouchDistance = null
})
// Double-click to reset zoom and pan
svg.addEventListener('dblclick', (e) => {
e.preventDefault()
zoomRef.value = 1
panRef.x = 0
panRef.y = 0
updateTransform()
this.updateZoomDisplay(container, zoomRef.value)
})
}
private addZoomControls(container: HTMLElement, localZoom: { value: number }, localPan: { x: number, y: number }, updateTransform: () => void) {
const controlsContainer = container.createDiv({ cls: 'sqlseal-zoom-controls' })
// Animation state
let isAnimating = false
const animateZoom = (targetZoom: number, targetPanX: number, targetPanY: number, duration: number = 200) => {
if (isAnimating) return
isAnimating = true
const startZoom = localZoom.value
const startPanX = localPan.x
const startPanY = localPan.y
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
// Easing function - ease out cubic
const easeProgress = 1 - Math.pow(1 - progress, 3)
localZoom.value = startZoom + (targetZoom - startZoom) * easeProgress
localPan.x = startPanX + (targetPanX - startPanX) * easeProgress
localPan.y = startPanY + (targetPanY - startPanY) * easeProgress
updateTransform()
this.updateZoomDisplay(container, localZoom.value)
if (progress < 1) {
requestAnimationFrame(animate)
} else {
isAnimating = false
// Ensure final values are exact
localZoom.value = targetZoom
localPan.x = targetPanX
localPan.y = targetPanY
updateTransform()
this.updateZoomDisplay(container, localZoom.value)
}
}
requestAnimationFrame(animate)
}
// Reset button (home) - first
const resetBtn = controlsContainer.createEl('button', {
cls: 'sqlseal-zoom-btn',
text: '⌂',
attr: { title: 'Reset View' }
})
resetBtn.addEventListener('click', () => {
animateZoom(1, 0, 0, 300)
})
// Zoom in button - second
const zoomInBtn = controlsContainer.createEl('button', {
cls: 'sqlseal-zoom-btn',
text: '+',
attr: { title: 'Zoom In' }
})
zoomInBtn.addEventListener('click', () => {
const newZoom = Math.min(5, localZoom.value * 1.2)
animateZoom(newZoom, localPan.x, localPan.y, 150)
})
// Zoom out button - third
const zoomOutBtn = controlsContainer.createEl('button', {
cls: 'sqlseal-zoom-btn',
text: '',
attr: { title: 'Zoom Out' }
})
zoomOutBtn.addEventListener('click', () => {
const newZoom = Math.max(0.1, localZoom.value / 1.2)
animateZoom(newZoom, localPan.x, localPan.y, 150)
})
// Zoom level display
const zoomDisplay = controlsContainer.createDiv({
cls: 'sqlseal-zoom-display'
})
this.updateZoomDisplay(container, localZoom.value)
}
private updateZoomDisplay(container: HTMLElement, zoom: number) {
const display = container.querySelector('.sqlseal-zoom-display')
if (display) {
display.textContent = `${Math.round(zoom * 100)}%`
}
}
private addInteractivity(container: HTMLElement, schema: DetailedTableInfo[]) {
// Add click handlers for tables to show detailed information
const tableElements = container.querySelectorAll('[id^="entity-"]')
tableElements.forEach((element, index) => {
if (index < schema.length) {
const table = schema[index]
element.addEventListener('click', () => {
this.showTableDetails(table)
})
element.addEventListener('mouseenter', () => {
element.classList.add('highlighted')
})
element.addEventListener('mouseleave', () => {
element.classList.remove('highlighted')
})
}
})
}
private showTableDetails(table: DetailedTableInfo) {
// Create a modal or popup with detailed table information
const modal = document.createElement('div')
modal.className = 'sqlseal-table-details-modal'
const content = document.createElement('div')
content.className = 'sqlseal-modal-content'
const title = document.createElement('h3')
title.textContent = `Table: ${table.name}`
content.appendChild(title)
// Columns section
const columnsTitle = document.createElement('h4')
columnsTitle.textContent = 'Columns'
content.appendChild(columnsTitle)
const columnsTable = document.createElement('table')
columnsTable.className = 'sqlseal-columns-table'
const headerRow = columnsTable.createTHead().insertRow()
headerRow.insertCell().textContent = 'Name'
headerRow.insertCell().textContent = 'Type'
headerRow.insertCell().textContent = 'Key'
headerRow.insertCell().textContent = 'Nullable'
headerRow.insertCell().textContent = 'Default'
const tbody = columnsTable.createTBody()
table.columns.forEach(column => {
const row = tbody.insertRow()
row.insertCell().textContent = column.name
row.insertCell().textContent = column.type
row.insertCell().textContent = column.isPrimaryKey ? 'PK' : ''
row.insertCell().textContent = column.notNull ? 'NO' : 'YES'
row.insertCell().textContent = column.defaultValue || ''
})
content.appendChild(columnsTable)
// Foreign keys section
if (table.foreignKeys.length > 0) {
const fkTitle = document.createElement('h4')
fkTitle.textContent = 'Foreign Keys'
content.appendChild(fkTitle)
const fkTable = document.createElement('table')
fkTable.className = 'sqlseal-fk-table'
const fkHeaderRow = fkTable.createTHead().insertRow()
fkHeaderRow.insertCell().textContent = 'Column'
fkHeaderRow.insertCell().textContent = 'References'
fkHeaderRow.insertCell().textContent = 'On Update'
fkHeaderRow.insertCell().textContent = 'On Delete'
const fkTbody = fkTable.createTBody()
table.foreignKeys.forEach(fk => {
const row = fkTbody.insertRow()
row.insertCell().textContent = fk.fromColumn
row.insertCell().textContent = `${fk.referencedTable}.${fk.toColumn}`
row.insertCell().textContent = fk.onUpdate
row.insertCell().textContent = fk.onDelete
})
content.appendChild(fkTable)
}
// Close button
const closeBtn = document.createElement('button')
closeBtn.textContent = '×'
closeBtn.className = 'sqlseal-modal-close'
closeBtn.onclick = () => modal.remove()
modal.appendChild(content)
modal.appendChild(closeBtn)
document.body.appendChild(modal)
// Close on backdrop click
modal.onclick = (e) => {
if (e.target === modal) modal.remove()
}
}
private showFallbackSchema(container: HTMLElement, schema: DetailedTableInfo[]) {
container.empty()
const fallbackContainer = container.createDiv({
cls: 'sqlseal-fallback-schema'
})
const title = fallbackContainer.createEl('h3', {
text: 'Database Schema (Fallback View)'
})
if (schema.length === 0) {
fallbackContainer.createEl('p', {
text: 'No tables found in the database.',
cls: 'sqlseal-empty-schema'
})
return
}
schema.forEach(table => {
const tableContainer = fallbackContainer.createDiv({
cls: 'sqlseal-fallback-table'
})
const tableTitle = tableContainer.createEl('h4', {
text: table.name,
cls: 'sqlseal-fallback-table-title'
})
if (table.columns.length > 0) {
const columnsTable = tableContainer.createEl('table', {
cls: 'sqlseal-fallback-columns-table'
})
const headerRow = columnsTable.createTHead().insertRow()
headerRow.insertCell().textContent = 'Column'
headerRow.insertCell().textContent = 'Type'
headerRow.insertCell().textContent = 'Key'
headerRow.insertCell().textContent = 'Constraints'
const tbody = columnsTable.createTBody()
table.columns.forEach(column => {
const row = tbody.insertRow()
row.insertCell().textContent = column.name
row.insertCell().textContent = column.type
let keyType = ''
if (column.isPrimaryKey) keyType = 'PK'
else if (table.foreignKeys.some(fk => fk.fromColumn === column.name)) keyType = 'FK'
row.insertCell().textContent = keyType
const constraints = []
if (column.notNull) constraints.push('NOT NULL')
if (column.defaultValue !== null) constraints.push(`DEFAULT ${column.defaultValue}`)
row.insertCell().textContent = constraints.join(', ')
})
}
if (table.foreignKeys.length > 0) {
const fkTitle = tableContainer.createEl('h5', {
text: 'Foreign Keys',
cls: 'sqlseal-fallback-fk-title'
})
const fkList = tableContainer.createEl('ul', {
cls: 'sqlseal-fallback-fk-list'
})
table.foreignKeys.forEach(fk => {
const fkItem = fkList.createEl('li')
fkItem.textContent = `${fk.fromColumn}${fk.referencedTable}.${fk.toColumn}`
})
}
})
}
}

View file

@ -1,3 +1,4 @@
// Legacy styles - kept for backward compatibility
.sqlseal-tv-container {
gap: 1em;
}
@ -26,4 +27,362 @@
flex-direction: row;
gap: 1em;
justify-content: space-between;
}
// New Mermaid-based schema visualizer styles
.sqlseal-mermaid-container {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--background-primary);
svg {
width: 100%;
height: 100%;
display: block;
}
// Interactive elements
[id^="entity-"] {
cursor: pointer;
transition: all 0.2s ease;
&:hover {
opacity: 0.8;
}
&.highlighted {
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
}
}
// Relationship lines and arrows - make them more visible
.er.relationshipLine {
stroke: var(--text-accent) !important;
stroke-width: 2px !important;
opacity: 0.8;
}
// Relationship labels
.er.relationshipLabel {
fill: var(--text-accent) !important;
font-family: var(--font-interface);
font-size: 11px !important;
font-weight: 500;
}
// Arrow markers for relationships
.er marker {
fill: var(--text-accent) !important;
stroke: var(--text-accent) !important;
}
// Entity boxes
.er.entityBox {
fill: var(--background-secondary);
stroke: var(--background-modifier-border);
stroke-width: 1px;
}
// Entity labels
.er.entityLabel {
fill: var(--text-normal);
font-family: var(--font-interface);
font-size: 14px;
font-weight: 600;
}
// Attribute text
.er.attributeBoxEven,
.er.attributeBoxOdd {
fill: var(--background-primary-alt);
stroke: var(--background-modifier-border);
}
.er.attributeBoxEven {
fill: var(--background-primary);
}
// Enhance cardinality symbols (crow's foot notation)
.er .cardinality text {
fill: var(--text-accent) !important;
font-family: var(--font-interface);
font-size: 14px !important;
font-weight: bold;
}
// Ensure all relationship elements are visible
path[id*="relationship"] {
stroke: var(--text-accent) !important;
stroke-width: 2px !important;
marker-end: url(#arrowhead) !important;
}
// Style the actual relationship paths
g[id*="relationship"] path {
stroke: var(--text-accent) !important;
stroke-width: 2px !important;
fill: none !important;
}
// Make sure arrowheads are visible
defs marker path {
fill: var(--text-accent) !important;
stroke: var(--text-accent) !important;
}
}
// Pan and zoom controls - minimalist bottom right
.sqlseal-zoom-controls {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
align-items: center;
gap: 2px;
z-index: 10;
.sqlseal-zoom-btn {
width: 28px;
height: 28px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
color: var(--text-normal);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:hover {
background: var(--background-modifier-hover);
border-color: var(--text-accent);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0) scale(0.95);
}
}
.sqlseal-zoom-display {
font-size: 10px;
color: var(--text-muted);
font-family: var(--font-monospace);
padding: 2px 6px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
margin-left: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 42px;
text-align: center;
font-weight: 500;
}
}
// Make the mermaid container relative for absolute positioning of controls
.sqlseal-mermaid-container {
position: relative;
svg {
// Enable smooth transitions for pan/zoom
transition: none; // Disable transitions during interaction
// Cursor styles for interaction states
&.grabbing {
cursor: grabbing !important;
}
}
.pan-zoom-group {
transform-origin: 0 0;
}
}
// Modal styles for table details
.sqlseal-table-details-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.sqlseal-modal-content {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 24px;
max-width: 80vw;
max-height: 80vh;
overflow-y: auto;
position: relative;
h3, h4 {
color: var(--text-normal);
margin: 0 0 16px 0;
}
h4 {
margin-top: 24px;
font-size: 1.1em;
}
}
.sqlseal-modal-close {
position: absolute;
top: 12px;
right: 16px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 4px;
&:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
}
}
// Table styles for modal content
.sqlseal-columns-table,
.sqlseal-fk-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-family: var(--font-interface);
th, td {
border: 1px solid var(--background-modifier-border);
padding: 8px 12px;
text-align: left;
}
th {
background: var(--background-secondary);
font-weight: 600;
color: var(--text-normal);
}
td {
background: var(--background-primary);
color: var(--text-normal);
}
tr:nth-child(even) td {
background: var(--background-primary-alt);
}
}
// Error message styling
.sqlseal-error {
color: var(--text-error);
padding: 16px;
text-align: center;
font-style: italic;
}
// Fallback schema styles
.sqlseal-fallback-schema {
padding: 16px;
font-family: var(--font-interface);
h3 {
color: var(--text-normal);
margin: 0 0 20px 0;
text-align: center;
}
.sqlseal-empty-schema {
color: var(--text-muted);
text-align: center;
font-style: italic;
padding: 40px;
}
}
.sqlseal-fallback-table {
margin-bottom: 24px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
overflow: hidden;
.sqlseal-fallback-table-title {
background: var(--background-secondary);
color: var(--text-normal);
margin: 0;
padding: 12px 16px;
font-size: 1.1em;
font-weight: 600;
border-bottom: 1px solid var(--background-modifier-border);
}
.sqlseal-fallback-fk-title {
color: var(--text-normal);
margin: 16px 16px 8px 16px;
font-size: 0.95em;
font-weight: 600;
}
.sqlseal-fallback-fk-list {
margin: 0 16px 16px 16px;
padding-left: 20px;
li {
color: var(--text-muted);
font-size: 0.9em;
margin-bottom: 4px;
font-family: var(--font-monospace);
}
}
}
.sqlseal-fallback-columns-table {
width: 100%;
border-collapse: collapse;
margin: 0;
th, td {
border: none;
border-bottom: 1px solid var(--background-modifier-border);
padding: 8px 16px;
text-align: left;
font-size: 0.9em;
}
th {
background: var(--background-primary-alt);
font-weight: 600;
color: var(--text-normal);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
background: var(--background-primary);
color: var(--text-normal);
}
tr:nth-child(even) td {
background: var(--background-primary-alt);
}
tr:last-child td {
border-bottom: none;
}
}

View file

@ -16,7 +16,7 @@ export class StatsRenderer implements ICellRendererComp {
this.sync()
}
public init(params: ICellRendererParams<TableConfiguration, string, GlobalTablesView>): void {
public init(params: ICellRendererParams<TableConfiguration, string, GlobalTablesView>) {
const { value, data, context } = params;
this.context = context
this.data = data!
@ -30,11 +30,11 @@ export class StatsRenderer implements ICellRendererComp {
requestAnimationFrame(async () => { this.sync() })
// Watching for the changes
this.setupWatchersAsync(context, data!)
this.setupWatchers(context, data!)
}
async setupWatchersAsync(context: GlobalTablesView, data: TableConfiguration) {
async setupWatchers(context: GlobalTablesView, data: TableConfiguration) {
this.reg = context.sync.getRegistrator()
this.eventName = await context.sync.getEventNameForAlias('/', data!.name)
this.reg.on(this.eventName, this.syncFn)

File diff suppressed because one or more lines are too long