feat(ui): relationships sidebar panel + schema data (#21, ADR-0046 DB2/DB4)

The left column now stacks a Tables panel over a Relationships panel.
Each relationship renders as three narrow lines — its name, then the
endpoints broken at the arrow (Customers.id -> / indented
Orders.customer_id) — ellipsized past the inner width. The panel is
content-sized within [5 rows ("(none)" when empty), half the column];
the Tables panel keeps the rest (>=3 rows). Phase C adds focus+scroll
for content beyond the cap (clipped for now).

Data path: a new worker Request::ReadAllRelationships +
Database::read_all_relationships returns full RelationshipSchema
records; the runtime posts them via a RelationshipsRefreshed event
alongside the schema-cache refresh, and the App holds them in a new
`relationships` field.

ADR deviation (recorded in ADR-0046 DB2 + index): DB2 specified this
data on SchemaCache; it lives on the App instead — SchemaCache is
walker/completion-facing and needs only relationship names (untouched),
while the full records are UI-only, so App is the cleaner home and it
avoids editing ~23 SchemaCache literals. No behavioural difference.

Tests: panel-height bounds, the three-line render, the empty "(none)"
case, a snapshot, read_all_relationships end-to-end (real DB via the
m:n junction), and the event->field handler.
This commit is contained in:
claude@clouddev1
2026-06-10 18:44:27 +00:00
parent 386627a262
commit 94825d0f36
12 changed files with 324 additions and 26 deletions
+154 -1
View File
@@ -36,6 +36,24 @@ const fn sidebar_visible(total_width: u16) -> bool {
total_width > SIDEBAR_MIN_WIDTH
}
/// Height (including borders) of the Relationships sub-panel within the
/// left column (ADR-0046 DB4): floored at 5 rows (so an empty panel
/// shows "(none)"), grown with `content_rows` up to half the column,
/// and never so tall that the Tables panel above drops below 3 rows.
const fn relationships_panel_height(col_h: u16, content_rows: u16) -> u16 {
let want = content_rows + 2; // + top/bottom borders
let mut h = if want < 5 { 5 } else { want };
let cap = col_h / 2; // never more than half the column
if h > cap {
h = cap;
}
let max_h = col_h.saturating_sub(3); // leave Tables at least 3 rows
if h > max_h {
h = max_h;
}
h
}
pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
let area = frame.area();
paint_background(theme, frame, area);
@@ -60,7 +78,17 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(app, theme, frame, columns[0]);
// ADR-0046 DB4: the sidebar stacks Tables (top) over a
// Relationships panel (bottom), the latter content-sized within
// [5 rows, half the column].
let rel_content = (app.relationships.len() as u16).saturating_mul(3);
let rel_h = relationships_panel_height(columns[0].height, rel_content);
let sidebar = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(rel_h)])
.split(columns[0]);
render_items_panel(app, theme, frame, sidebar[0]);
render_relationships_panel(app, theme, frame, sidebar[1]);
render_right_column(app, theme, frame, columns[1]);
} else {
render_right_column(app, theme, frame, outer[0]);
@@ -710,6 +738,68 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
frame.render_widget(paragraph, area);
}
/// The Relationships sub-panel below the Tables list (ADR-0046 DB2). In
/// the narrow (unfocused) column each relationship is three lines — its
/// name, then the endpoints broken at the arrow to fit — every line
/// ellipsized past the inner width. Phase C adds focus + scroll for the
/// overflow; for now content beyond the panel's height is clipped.
fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.relationships_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
if app.relationships.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
crate::t!("panel.relationships_empty"),
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::ITALIC),
)))
.block(block);
frame.render_widget(placeholder, area);
return;
}
let inner_w = area.width.saturating_sub(2) as usize;
let name_style = Style::default().fg(theme.fg);
let detail_style = Style::default().fg(theme.muted);
let mut lines: Vec<Line<'static>> = Vec::new();
for rel in &app.relationships {
lines.push(Line::from(Span::styled(
ellipsize(&rel.name, inner_w),
name_style,
)));
let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style)));
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
/// Truncate `s` to `width` display columns, appending an ellipsis when
/// it overflows (ADR-0046 DB2). Assumes one column per character.
fn ellipsize(s: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if s.chars().count() <= width {
return s.to_string();
}
let mut out: String = s.chars().take(width.saturating_sub(1)).collect();
out.push('…');
out
}
fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
@@ -2635,4 +2725,67 @@ mod tests {
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}");
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}");
}
#[test]
fn relationships_panel_height_is_content_sized_within_bounds() {
// ADR-0046 DB4: empty floors at 5; grows with content; capped at
// half the column; leaves the Tables panel at least 3 rows.
assert_eq!(relationships_panel_height(40, 0), 5); // empty floor
assert_eq!(relationships_panel_height(40, 6), 8); // 6 content + borders
assert_eq!(relationships_panel_height(40, 30), 20); // capped at half
assert_eq!(relationships_panel_height(7, 0), 3); // tiny: Tables keeps 3
}
fn one_relationship() -> crate::persistence::RelationshipSchema {
use crate::dsl::action::ReferentialAction;
crate::persistence::RelationshipSchema {
name: "Customers_Orders".to_string(),
parent_table: "Customers".to_string(),
parent_columns: vec!["id".to_string()],
child_table: "Orders".to_string(),
child_columns: vec!["customer_id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::Cascade,
}
}
#[test]
fn relationships_panel_lists_each_relationship() {
// ADR-0046 DB2: name, then endpoints broken at the arrow.
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
app.relationships = vec![one_relationship()];
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 110, 24);
assert!(out.contains("Relationships"), "panel title present:\n{out}");
assert!(out.contains("Customers_Orders"), "relationship name:\n{out}");
assert!(
out.lines().any(|l| l.contains("Customers.id ->")),
"parent endpoint, broken at the arrow:\n{out}"
);
assert!(
out.lines().any(|l| l.contains("Orders.customer_id")),
"child endpoint, indented:\n{out}"
);
}
#[test]
fn empty_relationships_panel_shows_none() {
let mut app = App::new();
app.tables = vec!["Customers".to_string()];
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 110, 24);
assert!(out.contains("Relationships"), "panel title present:\n{out}");
assert!(out.contains("(none)"), "empty placeholder:\n{out}");
}
#[test]
fn relationships_panel_snapshot() {
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
app.relationships = vec![one_relationship()];
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 110, 24);
insta::assert_snapshot!("relationships_panel_dark", snapshot);
}
}