Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## 2026-11-03

- Schema and Linked view cards no longer end up on top of each other after expanding, resizing, dragging or loading a card (2026-11-03)
- New users now get a welcome dialog on first launch (web and desktop) explaining how to connect and what to try; reopen it from Help ▸ Welcome… (2026-11-03)
- File ▸ Show crash log opens the folder with dbscope.log for bug reports (desktop) (2026-11-03)
- dbscope_pg_proxy can be pinned to one database with --allow-db-host, and caps each result at --max-rows rows (default 100000, flagged truncated) (2026-11-03)
Expand Down
7 changes: 7 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ rg -n '\[SIMPLE\]' TODO.md

<!-- New entries go here, newest first. -->

### **[P3]** Place new Linked view cards using their real size, not a guessed one

- **Where.** `lib/linked/linked_view.dart:464` (`_placeNextTo`).
- **Why.** It looks for a free slot as if the new card were `source.width` wide and four rows tall. The canvas now pushes neighbours away once the card's real size is known, so nothing overlaps, but a tall or wide card can make its neighbours jump a moment after it appears. Placing it with its real size (or the compact-column count, which is known from the FKs) would avoid the jump.
- **Effort.** `<1h`.
- **Blocker.** none.
- **Noticed.** 2026-11-03, while fixing overlapping schema diagram cards.
### **[P2]** Find out whether the release build still needs `disable-library-validation`

- **Where.** `macos/Runner/Release.entitlements` (`com.apple.security.cs.disable-library-validation`)
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Grid view modes (`enum GridViewMode { table, typed }`, in `lib/grid/grid_data.da

## Linked view (FK graph canvas)

`LinkedTab` (`lib/linked/`) is the third row-explorer alongside `TableTab` and `NavigatorTab`. The canvas is a `Stack` of free-floating, drag-by-header nodes connected by bezier edges; each node is either a `LinkedRowNode` (single row → `PropertyInspector`) or a `LinkedListNode` (paginated `KeysetPager` rows the user clicks to promote into row nodes). Outbound FK clicks in the inspector spawn row nodes; inbound clicks spawn list nodes filtered by the inbound FK (same `InboundFilter` the navigator uses). The cached `foreignKeys` list is loaded once per tab. Nodes are a fixed `_kNodeWidth × _kNodeHeight` so the edge painter can anchor connectors to rim midpoints without measuring widget sizes — a clean shape but it constrains body content. Background-pan lives as a `Positioned.fill` at the bottom of the stack so node hit-tests preempt it (otherwise horizontal drags inside an inspector field would pan the canvas instead of selecting text). Seed entry points: sidebar context menu → "Open as Linked" (sets `seedSchema`/`seedTable`, bootstraps a list node on first build), or empty `LinkedTab` + "Add table" header button.
`LinkedTab` (`lib/linked/`) is the third row-explorer alongside `TableTab` and `NavigatorTab`. The canvas is a `Stack` of free-floating, drag-by-header nodes connected by bezier edges; each node is either a `LinkedRowNode` (single row → `PropertyInspector`) or a `LinkedListNode` (paginated `KeysetPager` rows the user clicks to promote into row nodes). Outbound FK clicks in the inspector spawn row nodes; inbound clicks spawn list nodes filtered by the inbound FK (same `InboundFilter` the navigator uses). The cached `foreignKeys` list is loaded once per tab. Nodes are a fixed `_kNodeWidth × _kNodeHeight` so the edge painter can anchor connectors to rim midpoints without measuring widget sizes — a clean shape but it constrains body content. Background-pan lives as a `Positioned.fill` at the bottom of the stack so node hit-tests preempt it (otherwise horizontal drags inside an inspector field would pan the canvas instead of selecting text). Cards never overlap: whenever a card appears, changes size (width auto-fit, columns loading, a manual resize) or is dropped after a drag, the canvas runs `cascadeSeparate` from `package:schema_viewer` with that card pinned, the same pass the Schema viewer runs on expand and drag. Seed entry points: sidebar context menu → "Open as Linked" (sets `seedSchema`/`seedTable`, bootstraps a list node on first build), or empty `LinkedTab` + "Add table" header button.

Row cards are rendered via the shared `SchemaCard` widget (see "Schema viewer"), with `SchemaFieldSpec.valueDisplay` populated from the loaded `row` map — so a Linked row card shows live data values instead of column type labels once the row loads. Edge styling (gradient stroke + two animated chevrons per edge, 3.5s cycle) is identical to the Schema viewer's `_EdgePainter` so the two views feel like one product.

Expand Down
72 changes: 64 additions & 8 deletions lib/linked/parts/canvas.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin {
/// width alone so a manual resize is sticky.
final _autoSizedOnce = <String>{};

/// Each row node's size as of the last build. A card that appears or
/// changes size (width auto-fit, columns loading, a manual resize)
/// pushes its neighbours away so cards never sit on top of each other.
/// Null until the first build, which takes the layout as given.
Map<String, Size>? _lastSizes;

/// Drives the chevron animation on edges. 0..1, wraps every cycle.
late final Ticker _ticker;
double _tickerValue = 0;
Expand Down Expand Up @@ -153,6 +159,7 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin {
);
if (node != null && _dragDelta != Offset.zero) {
node.position += _dragDelta;
_separateFrom({node.id});
}
setState(() {
_dragNodeId = null;
Expand Down Expand Up @@ -189,6 +196,51 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin {
AppState.instance.notifyChange();
}

/// Push neighbours away from every card that is new or changed size
/// since the last build. The first build only records sizes, so a
/// restored canvas keeps the layout the user left it in.
void _separateResizedNodes(
List<LinkedRowNode> rows,
List<ForeignKey> foreignKeys,
) {
final sizes = <String, Size>{
for (final node in rows) node.id: Size(node.width, _nodeHeightFor(node, foreignKeys)),
};
final previous = _lastSizes;
_lastSizes = sizes;
if (previous == null) return;
final grown = <String>{
for (final entry in sizes.entries)
if (previous[entry.key] != entry.value) entry.key,
};
if (grown.isNotEmpty) _separateFrom(grown, sizes: sizes);
}

/// Push every row node that overlaps one of [pinnedIds] (or a node
/// pushed in turn) out of the way. [pinnedIds] keep their positions.
void _separateFrom(Set<String> pinnedIds, {Map<String, Size>? sizes}) {
final foreignKeys = widget.tab.foreignKeys;
final rows = {
for (final node in widget.tab.nodes)
if (node is LinkedRowNode) node.id: node,
};
final positions = {for (final node in rows.values) node.id: node.position};
final moved = cascadeSeparate(
movedIds: pinnedIds,
positions: positions,
dimsFor: (id) {
final node = rows[id];
if (node == null) return null;
final size = sizes?[id] ?? Size(node.width, _nodeHeightFor(node, foreignKeys));
return (w: size.width, h: size.height);
},
allIds: positions.keys,
);
for (final id in moved) {
rows[id]!.position = positions[id]!;
}
}

/// Build only the LinkedRowNode positions. `_topNodeId` (if any) is
/// rendered last so it stacks on top.
List<Widget> _buildRowNodes(Offset pan) {
Expand All @@ -202,17 +254,21 @@ class _CanvasState extends State<_Canvas> with SingleTickerProviderStateMixin {
final top = rows.removeAt(topIndex);
rows.add(top);
}
// Auto-fit the width once FKs are loaded and the user hasn't
// resized the card themselves. We only widen, never shrink — so a
// manual resize sticks. Done for every card before any is placed,
// so the separation pass below sees final sizes.
for (final node in rows) {
if (widget.tab.fksLoaded && !_autoSizedOnce.contains(node.id)) {
final fit = _autoWidthFor(node, foreignKeys);
if (fit > node.width) node.width = fit;
_autoSizedOnce.add(node.id);
}
}
_separateResizedNodes(rows, foreignKeys);
return [
for (final node in rows)
() {
// Auto-fit the width once FKs are loaded and the user hasn't
// resized the card themselves. We only widen, never shrink —
// so a manual resize sticks.
if (widget.tab.fksLoaded && !_autoSizedOnce.contains(node.id)) {
final fit = _autoWidthFor(node, foreignKeys);
if (fit > node.width) node.width = fit;
_autoSizedOnce.add(node.id);
}
final height = _nodeHeightFor(node, foreignKeys);
final width = node.width;
return Positioned(
Expand Down
98 changes: 72 additions & 26 deletions packages/schema_viewer/lib/src/schema_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Size layoutSchemaNodes(
List<SchemaEdgeData> edges, {
SchemaLayoutMode mode = SchemaLayoutMode.lr,
double colGap = 80,
double rowGap = 24,
double rowGap = kSchemaNodeMargin,
}) {
if (nodes.isEmpty) return Size.zero;
if (edges.isEmpty) {
Expand Down Expand Up @@ -251,55 +251,101 @@ Size _layoutRanked(

/// Cascading AABB separation used by the canvas when a node moves
/// (drag) or grows (expand). [movedIds] are immovable; their positions
/// are authoritative. Mutates [positions] in place and returns the set
/// of node ids whose position changed. Mirrors admin_site/dbmlLayout.ts
/// cascadeSeparate.
/// are authoritative. Every other node that ends up within
/// [kSchemaNodeMargin] of a node it touches is pushed away along the
/// axis of least overlap, and each pushed node is then checked against
/// every other node (pinned ones included), so a push can never land a
/// card on top of another. Mutates [positions] in place and returns the
/// set of node ids whose position changed.
///
/// Each pass moves the push one step further down a chain of touching
/// cards, so [maxPasses] defaults to enough passes for a chain through
/// every node. Two pinned nodes that overlap each other are left alone.
Set<String> cascadeSeparate({
required Set<String> movedIds,
required Map<String, Offset> positions,
required ({double w, double h})? Function(String id) dimsFor,
required Iterable<String> allIds,
int maxPasses = 8,
int? maxPasses,
}) {
final ids = allIds.toList();
final passLimit = maxPasses ?? ids.length * 2 + 8;
final changed = <String>{};
var work = Set<String>.of(movedIds);
for (var pass = 0; pass < maxPasses; pass++) {
for (var pass = 0; pass < passLimit && work.isNotEmpty; pass++) {
final next = <String>{};
for (final id in work) {
final aPosition = positions[id];
final da = dimsFor(id);
if (aPosition == null || da == null) continue;
for (final otherId in allIds) {
if (da == null) continue;
for (final otherId in ids) {
if (otherId == id) continue;
if (movedIds.contains(otherId)) continue;
final idPinned = movedIds.contains(id);
final otherPinned = movedIds.contains(otherId);
if (idPinned && otherPinned) continue;
final aPosition = positions[id];
final bPosition = positions[otherId];
final db = dimsFor(otherId);
if (bPosition == null || db == null) continue;
if (aPosition == null || bPosition == null || db == null) continue;
final minDx = (da.w + db.w) / 2 + kSchemaNodeMargin;
final minDy = (da.h + db.h) / 2 + kSchemaNodeMargin;
// Centre-to-centre distance, from `id` to `otherId`.
final dx = (bPosition.dx + db.w / 2) - (aPosition.dx + da.w / 2);
final dy = (bPosition.dy + db.h / 2) - (aPosition.dy + da.h / 2);
final ox = minDx - dx.abs();
final oy = minDy - dy.abs();
if (ox <= 0 || oy <= 0) continue;
if (ox < oy) {
final push = ox + 0.5;
positions[otherId] = Offset(
bPosition.dx + (dx >= 0 ? push : -push),
bPosition.dy,
);
} else {
final push = oy + 0.5;
positions[otherId] = Offset(
bPosition.dx,
bPosition.dy + (dy >= 0 ? push : -push),
);
// The pinned side never moves. Otherwise `otherId` gets pushed,
// which keeps a cascade flowing outward from where it started.
final moverId = otherPinned ? id : otherId;
final sign = moverId == otherId ? 1.0 : -1.0;
final moverPosition = positions[moverId]!;
final moverDims = moverId == id ? da : db;
// The four ways out, shortest first: the two "away" pushes are
// the overlap on each axis, the two "back through" pushes clear
// the far side instead. Take the shortest one that lands clear
// of every card, else the shortest that at least avoids pinned
// cards. Without that, a card squeezed against a pinned card
// bounces between its neighbours until the passes run out.
final awayX = (dx >= 0 ? 1.0 : -1.0) * sign;
final awayY = (dy >= 0 ? 1.0 : -1.0) * sign;
final candidates = <Offset>[
Offset(awayX * (ox + 0.5), 0),
Offset(0, awayY * (oy + 0.5)),
Offset(-awayX * (2 * minDx - ox + 0.5), 0),
Offset(0, -awayY * (2 * minDy - oy + 0.5)),
]..sort((a, b) => a.distance.compareTo(b.distance));
bool crowds(Offset at, {required bool pinnedOnly}) {
for (final nearId in ids) {
if (nearId == moverId) continue;
if (pinnedOnly && !movedIds.contains(nearId)) continue;
final nearPosition = positions[nearId];
final nearDims = dimsFor(nearId);
if (nearPosition == null || nearDims == null) continue;
final gapX = ((nearPosition.dx + nearDims.w / 2) - (at.dx + moverDims.w / 2)).abs();
final gapY = ((nearPosition.dy + nearDims.h / 2) - (at.dy + moverDims.h / 2)).abs();
if (gapX < (nearDims.w + moverDims.w) / 2 + kSchemaNodeMargin &&
gapY < (nearDims.h + moverDims.h) / 2 + kSchemaNodeMargin) {
return true;
}
}
return false;
}
changed.add(otherId);
next.add(otherId);

Offset? firstWhere(bool Function(Offset candidate) test) {
for (final candidate in candidates) {
if (test(candidate)) return candidate;
}
return null;
}

final push = firstWhere((c) => !crowds(moverPosition + c, pinnedOnly: false)) ??
firstWhere((c) => !crowds(moverPosition + c, pinnedOnly: true)) ??
candidates.first;
positions[moverId] = moverPosition + push;
changed.add(moverId);
next.add(moverId);
}
}
if (next.isEmpty) break;
work = next;
}
return changed;
Expand Down
Loading
Loading