Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ec5d971
feat(sidebar): add ER diagram button to database toolbar
Blankll Jul 22, 2026
18f32c6
feat(queries): wire ER diagram button to openErDiagramTab
Blankll Jul 22, 2026
6144773
chore: bump data-studio-agent to v0.1.4
Blankll Jul 22, 2026
7884110
refactor(sidebar): move ER diagram button to database actions dropdown
Blankll Jul 22, 2026
0795ec6
fix(queries): show all schemas in database-level ER diagram
Blankll Jul 22, 2026
7a0cbda
feat(er-diagram): improve zoom, add zoom buttons and schema selector,…
Blankll Jul 22, 2026
b48ba5d
fix(er-diagram): fetch schemas from backend if not cached
Blankll Jul 22, 2026
7ad4625
fix(er-diagram): fix Select empty value issue and reorder zoom buttons
Blankll Jul 22, 2026
eea670b
fix(er-diagram): fetch schemas via direct invoke instead of store
Blankll Jul 22, 2026
f292a44
fix(er-diagram): fix lint error - remove useless v-bind on string lit…
Blankll Jul 22, 2026
1626992
fix(er-diagram): improve interaction, add node dragging, fix layout i…
Blankll Jul 22, 2026
e5b7af2
feat(er-diagram): add orthogonal routing, ctrl+zoom, focus mode, info…
Blankll Jul 22, 2026
8f091ac
fix(er-diagram): smooth drag via delta-offset, add tooltips and SVG o…
Blankll Jul 22, 2026
e9240f9
refactor(er-diagram): pure HTML rendering, engineering mode, canvas a…
Blankll Jul 22, 2026
d7bb12a
fix(er-diagram): right-click drag to pan canvas
Blankll Jul 22, 2026
3942e2e
fix(er-diagram): right-click drag pan with template-level events
Blankll Jul 23, 2026
5ce9999
fix(er-diagram): left-click pan, remove engineering view, cursor states
Blankll Jul 23, 2026
f6c9905
chore: lint auto-fix QueryTabs.vue
Blankll Jul 23, 2026
f03d27d
chore: remove engineering view comment from style section
Blankll Jul 23, 2026
4d0568f
fix(er-diagram): remove right-click pan, restore context menu
Blankll Jul 23, 2026
e8d3a02
fix(er-diagram): LR bezier routing, subtle selection, smoother drag
Blankll Jul 23, 2026
290033f
refactor(er-diagram): simplify to single TB layout, Record positions,…
Blankll Jul 23, 2026
44c803f
fix(er-diagram): prevent manualOverrides reset on layout recompute
Blankll Jul 23, 2026
ba1547b
fix(er-diagram): replace orthogonal routing with center-to-center bezier
Blankll Jul 23, 2026
c6cf601
fix(er-diagram): restore rightward orthogonal routing, remove focus-m…
Blankll Jul 23, 2026
4da220b
fix: lint
Blankll Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ http = "1"
log = "0.4"
futures = "0.3"
rand = "0.8"
data-studio-agent = { git = "https://github.com/geek-fun/data-studio-agent.git", tag = "v0.1.3" }
data-studio-agent = { git = "https://github.com/geek-fun/data-studio-agent.git", tag = "v0.1.4" }

# Archive extraction (JRE downloads)
flate2 = "1.0"
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/graph-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { DagreRel, DagreTable } from '@/components/er-diagram/graph-layout'
/**
* @jest-environment node
*/
import {
computeCanvasSize,
computeDagreLayout,
fitToScreen,
} from '@/components/er-diagram/graph-layout'

describe('computeDagreLayout', () => {
it('returns positions for A→B with finite coordinates, B below A', () => {
const tables: DagreTable[] = [
{ name: 'A', width: 220, height: 100 },
{ name: 'B', width: 220, height: 100 },
]
const rels: DagreRel[] = [
{ sourceTable: 'A', targetTable: 'B' },
]

const positions = computeDagreLayout(tables, rels)
const keys = Object.keys(positions)

expect(keys).toContain('A')
expect(keys).toContain('B')
expect(Number.isFinite(positions.A.x)).toBe(true)
expect(Number.isFinite(positions.A.y)).toBe(true)
expect(Number.isFinite(positions.B.x)).toBe(true)
expect(Number.isFinite(positions.B.y)).toBe(true)
expect(positions.B.y).toBeGreaterThan(positions.A.y)
})
})

describe('computeCanvasSize', () => {
it('computes size from table positions with padding', () => {
const positions: Record<string, { x: number, y: number }> = {
A: { x: 110, y: 80 },
B: { x: 410, y: 160 },
}
const getHeight = (name: string) => name === 'A' ? 100 : 100
const result = computeCanvasSize(positions, 220, getHeight, 80)

// A right edge = 110 + 110 + 80 = 300
// B right edge = 410 + 110 + 80 = 600
// B bottom edge = 160 + 50 + 80 = 290
// min 960 x 540
expect(result.width).toBe(960)
expect(result.height).toBe(540)
})

it('expands beyond minimum when tables are large', () => {
const positions: Record<string, { x: number, y: number }> = {
Big: { x: 1000, y: 800 },
}
const getHeight = () => 200
const result = computeCanvasSize(positions, 220, getHeight, 50)

// right edge = 1000 + 110 + 50 = 1160
// bottom edge = 800 + 100 + 50 = 950
expect(result.width).toBe(1160)
expect(result.height).toBe(950)
})

it('defaults to 960x540 for empty positions', () => {
const result = computeCanvasSize({}, 220, () => 0, 80)
expect(result.width).toBe(960)
expect(result.height).toBe(540)
})
})

describe('fitToScreen', () => {
it('zooms in (zoom >= 1) when content is smaller than viewport', () => {
const result = fitToScreen({ width: 100, height: 100 }, 800, 600)
expect(result.zoom).toBe(3)
expect(Number.isFinite(result.scrollX)).toBe(true)
expect(Number.isFinite(result.scrollY)).toBe(true)
})

it('zooms out (zoom < 1) when content is larger than viewport', () => {
const result = fitToScreen({ width: 2000, height: 1500 }, 800, 600)
expect(result.zoom).toBeCloseTo(0.4, 5)
expect(Number.isFinite(result.scrollX)).toBe(true)
expect(Number.isFinite(result.scrollY)).toBe(true)
})

it('centers content in viewport', () => {
const result = fitToScreen({ width: 400, height: 300 }, 1000, 800)
expect(result.zoom).toBe(2.5)
expect(result.scrollX).toBe(0)
expect(result.scrollY).toBe(25)
})

it('caps zoom between 0.1 and 3', () => {
const r1 = fitToScreen({ width: 10, height: 10 }, 10000, 10000)
expect(r1.zoom).toBe(3)
const r2 = fitToScreen({ width: 100000, height: 100000 }, 100, 100)
expect(r2.zoom).toBe(0.1)
})
})
141 changes: 141 additions & 0 deletions src/__tests__/graph-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { TableRect } from '@/components/er-diagram/types'
/**
* @jest-environment node
*/
import {
buildTableRectMap,
candidateRouteXs,
computeRelationshipPath,
rangesOverlap,
routeSideX,
} from '@/components/er-diagram/graph-routing'

const rectA: TableRect = { name: 'A', x: 0, y: 0, width: 220, height: 100 }
const rectB: TableRect = { name: 'B', x: 300, y: 0, width: 220, height: 100 }
const rectBlocker: TableRect = { name: 'Blocker', x: 150, y: 50, width: 220, height: 100 }

describe('rangesOverlap', () => {
it('returns true when ranges overlap', () => {
expect(rangesOverlap(0, 10, 5, 15)).toBe(true)
})

it('returns true when one range is inside another', () => {
expect(rangesOverlap(0, 20, 5, 10)).toBe(true)
})

it('returns true when ranges touch at a point', () => {
expect(rangesOverlap(0, 10, 10, 20)).toBe(true)
})

it('returns false when ranges do not overlap', () => {
expect(rangesOverlap(0, 10, 20, 30)).toBe(false)
})

it('returns false when ranges are adjacent but not touching', () => {
expect(rangesOverlap(0, 10, 11, 20)).toBe(false)
})
})

describe('routeSideX', () => {
it('returns rect.x - offset when routeX is left of rect', () => {
expect(routeSideX(rectA, -50)).toBe(0)
})

it('returns rect.x when routeX is left of rect with default offset 0', () => {
expect(routeSideX(rectA, -50)).toBe(0)
})

it('returns rect.x + width + offset when routeX is right of rect', () => {
expect(routeSideX(rectA, 300)).toBe(220)
})

it('uses provided offset when routeX is left of rect', () => {
expect(routeSideX(rectA, -50, 5)).toBe(-5)
})

it('uses provided offset when routeX is right of rect', () => {
expect(routeSideX(rectA, 300, 10)).toBe(230)
})
})

describe('candidateRouteXs', () => {
it('returns sorted candidates by proximity to source and target', () => {
const candidates = candidateRouteXs(rectA, rectB, [rectA, rectB])
expect(candidates.length).toBeGreaterThan(0)
// First candidate should be the best (closest to both rects)
expect(candidates[0]).toBeGreaterThan(rectA.x)
expect(candidates[0]).toBeLessThan(rectB.x + rectB.width)
})

it('includes the midpoint gap when rects are side by side', () => {
const candidates = candidateRouteXs(rectA, rectB, [rectA, rectB])
// rectA ends at 220, rectB starts at 300, gap is 80 >= 56 (ROUTE_PADDING)
// So midpoint should be (220 + 300) / 2 = 260
expect(candidates).toContain(260)
})

it('includes the right edge padding candidate', () => {
const candidates = candidateRouteXs(rectA, rectB, [rectA, rectB])
// maxRight is 300 + 220 = 520, so 520 + 56 = 576
expect(candidates).toContain(576)
})
})

describe('computeRelationshipPath', () => {
it('returns empty string when source table is not in rectMap', () => {
const rectMap: Record<string, TableRect> = { B: rectB }
expect(computeRelationshipPath('A', 'B', rectMap)).toBe('')
})

it('returns empty string when target table is not in rectMap', () => {
const rectMap: Record<string, TableRect> = { A: rectA }
expect(computeRelationshipPath('A', 'B', rectMap)).toBe('')
})

it('returns an orthogonal SVG path for two side-by-side rects', () => {
const rectMap: Record<string, TableRect> = { A: rectA, B: rectB }
const path = computeRelationshipPath('A', 'B', rectMap)
expect(path).toMatch(/^M /)
expect(path).toContain(' L ')
})

it('returns an orthogonal path even with a blocker between source and target', () => {
const rectMap: Record<string, TableRect> = {
A: rectA,
B: rectB,
Blocker: rectBlocker,
}
const path = computeRelationshipPath('A', 'B', rectMap)
expect(path).toMatch(/^M /)
expect(path).toContain(' L ')
})
})

describe('buildTableRectMap', () => {
it('builds a rect map from table names and positions', () => {
const nodePositions: Record<string, { x: number, y: number }> = {
A: { x: 110, y: 50 },
B: { x: 410, y: 50 },
}
const getHeight = (_name: string) => 100
const rectMap = buildTableRectMap(['A', 'B'], nodePositions, getHeight)
const keys = Object.keys(rectMap)
expect(keys).toHaveLength(2)
expect(keys).toContain('A')
expect(keys).toContain('B')
const rectA = rectMap.A
expect(rectA.x).toBe(0) // 110 - 220/2
expect(rectA.y).toBe(0) // 50 - 100/2
expect(rectA.width).toBe(220)
expect(rectA.height).toBe(100)
})

it('skips tables not found in nodePositions', () => {
const nodePositions: Record<string, { x: number, y: number }> = {
A: { x: 110, y: 50 },
}
const getHeight = (_name: string) => 100
const rectMap = buildTableRectMap(['A', 'B'], nodePositions, getHeight)
expect(Object.keys(rectMap)).toHaveLength(1)
})
})
12 changes: 6 additions & 6 deletions src/components/database-browser/QueryTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,13 @@ const isActiveTab = (tabId: string) => props.activeTabId === tabId
</script>

<template>
<div class="query-tabs flex items-stretch overflow-x-auto border-b border-border">
<div class="query-tabs border-b border-border flex items-stretch overflow-x-auto">
<!-- Tabs -->
<div class="flex flex-1 min-w-0 items-stretch">
<div
v-for="tab in tabs"
:key="tab.id"
class="tab-item group flex items-center gap-1 px-3 cursor-pointer transition-colors select-none text-xs border-r border-border"
class="tab-item group text-xs px-3 border-r border-border flex gap-1 cursor-pointer select-none transition-colors items-center"
:class="{
'bg-background text-foreground font-medium': isActiveTab(tab.id),
'bg-muted/30 text-muted-foreground hover:bg-background/50 hover:text-foreground': !isActiveTab(tab.id),
Expand Down Expand Up @@ -229,11 +229,11 @@ const isActiveTab = (tabId: string) => props.activeTabId === tabId

<!-- Close button -->
<button
class="rounded flex flex-shrink-0 h-4 w-4 transition-colors items-center justify-center hover:bg-muted-foreground/20 opacity-0 group-hover:opacity-100"
class="rounded opacity-0 flex flex-shrink-0 h-4 w-4 transition-colors items-center justify-center hover:bg-muted-foreground/20 group-hover:opacity-100"
:class="{ 'opacity-100': isActiveTab(tab.id) }"
@click="handleCloseClick($event, tab)"
>
<span class="i-lucide-x h-3 w-3 shrink-0" />
<span class="i-lucide-x shrink-0 h-3 w-3" />
</button>
</div>
</div>
Expand All @@ -242,11 +242,11 @@ const isActiveTab = (tabId: string) => props.activeTabId === tabId
<Button
variant="ghost"
size="icon"
class="shrink-0 h-7 w-7 text-muted-foreground hover:text-foreground"
class="text-muted-foreground shrink-0 h-7 w-7 hover:text-foreground"
:title="t('components.queryTabs.new')"
@click="handleNewTab"
>
<span class="i-lucide-plus h-3.5 w-3.5 shrink-0" />
<span class="i-lucide-plus shrink-0 h-3.5 w-3.5" />
</Button>

<!-- Unsaved changes dialog -->
Expand Down
Loading
Loading