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
+34
View File
@@ -252,6 +252,12 @@ pub struct App {
/// [`App::input_validity_verdict`] once typing pauses.
pub input_indicator: Option<crate::dsl::walker::Severity>,
pub tables: Vec<String>,
/// All relationships as full schema records, for the sidebar
/// relationships panel (ADR-0046 DB2). Refreshed by the runtime
/// alongside `tables`. Kept on the App (not `SchemaCache`) because
/// only the UI needs the details — the walker/completion need just
/// the names, which stay in `SchemaCache::relationships`.
pub relationships: Vec<crate::persistence::RelationshipSchema>,
/// Last successfully described table, shown in the output
/// pane until the next DDL operation.
pub current_table: Option<TableDescription>,
@@ -449,6 +455,7 @@ impl App {
hint: None,
input_indicator: None,
tables: Vec::new(),
relationships: Vec::new(),
current_table: None,
history: Vec::new(),
history_cursor: None,
@@ -721,6 +728,11 @@ impl App {
self.schema_cache = cache;
Vec::new()
}
AppEvent::RelationshipsRefreshed(relationships) => {
trace!(count = relationships.len(), "relationships refreshed");
self.relationships = relationships;
Vec::new()
}
AppEvent::PersistenceFatal {
operation,
path,
@@ -5098,6 +5110,28 @@ mod tests {
assert_eq!(app.input_cursor, 0);
}
#[test]
fn relationships_refreshed_event_updates_the_field() {
// ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
// App stores it for the sidebar relationships panel to render.
use crate::dsl::action::ReferentialAction;
let mut app = App::new();
assert!(app.relationships.is_empty());
app.update(AppEvent::RelationshipsRefreshed(vec![
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::NoAction,
},
]));
assert_eq!(app.relationships.len(), 1);
assert_eq!(app.relationships[0].name, "Customers_Orders");
}
#[test]
fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
// ADR-0046 DA3: the horizontal scroll offset must not leak from