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:
+34
@@ -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
|
||||
|
||||
@@ -837,6 +837,13 @@ enum Request {
|
||||
source: crate::dsl::grammar::IdentSource,
|
||||
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
||||
},
|
||||
/// All relationships as full schema records (name, parent/child
|
||||
/// tables + columns, referential actions). Feeds the sidebar
|
||||
/// relationships panel (ADR-0046 DB2); the walker only needs the
|
||||
/// names, which `ListNamesFor` already provides.
|
||||
ReadAllRelationships {
|
||||
reply: oneshot::Sender<Result<Vec<RelationshipSchema>, DbError>>,
|
||||
},
|
||||
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
|
||||
/// Replies with the metadata of the command that was undone, or
|
||||
/// `None` if there is nothing to undo (or undo is disabled).
|
||||
@@ -1787,6 +1794,14 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// All relationships as full schema records, for the sidebar
|
||||
/// relationships panel (ADR-0046 DB2).
|
||||
pub async fn read_all_relationships(&self) -> Result<Vec<RelationshipSchema>, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::ReadAllRelationships { reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
|
||||
/// `Ok(Some(meta))` reports the command that was undone;
|
||||
/// `Ok(None)` means nothing to undo (or undo is disabled).
|
||||
@@ -2774,6 +2789,9 @@ fn handle_request(
|
||||
let result = do_list_names_for(conn, source);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Request::ReadAllRelationships { reply } => {
|
||||
let _ = reply.send(read_all_relationships(conn));
|
||||
}
|
||||
// Undo/redo/peek/batch are intercepted in `worker_loop` (they
|
||||
// need `&mut conn` or persistent batch state) and never reach
|
||||
// here. Listed explicitly so a new variant still forces a
|
||||
|
||||
@@ -165,6 +165,10 @@ pub enum AppEvent {
|
||||
/// posts this alongside `TablesRefreshed` after project
|
||||
/// load and after every successful DDL.
|
||||
SchemaCacheRefreshed(crate::completion::SchemaCache),
|
||||
/// Refreshed list of relationships as full schema records, for the
|
||||
/// sidebar relationships panel (ADR-0046 DB2). Posted by the runtime
|
||||
/// alongside `SchemaCacheRefreshed` after every schema refresh.
|
||||
RelationshipsRefreshed(Vec<crate::persistence::RelationshipSchema>),
|
||||
/// A persistence failure occurred (ADR-0015 §8). The
|
||||
/// application surfaces a fatal banner and exits cleanly so
|
||||
/// the message remains above the shell prompt.
|
||||
|
||||
@@ -443,6 +443,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("panel.hint_empty", &[]),
|
||||
("panel.hint_title", &[]),
|
||||
("panel.output_title", &[]),
|
||||
("panel.relationships_empty", &[]),
|
||||
("panel.relationships_title", &[]),
|
||||
("panel.tables_empty", &[]),
|
||||
("panel.tables_title", &[]),
|
||||
("status.no_project", &[]),
|
||||
|
||||
@@ -853,6 +853,8 @@ status:
|
||||
panel:
|
||||
tables_title: "Tables"
|
||||
tables_empty: "(none yet)"
|
||||
relationships_title: "Relationships"
|
||||
relationships_empty: "(none)"
|
||||
hint_empty: "Type a command — press Tab for options, `help` for a list"
|
||||
# Panel titles for the output and hint panels (rendered inside
|
||||
# the rounded border, hence the leading/trailing space).
|
||||
|
||||
@@ -1079,6 +1079,13 @@ async fn refresh_schema_cache(
|
||||
) {
|
||||
let cache = build_schema_cache(database).await;
|
||||
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
|
||||
// ADR-0046 DB2: full relationship records for the sidebar panel.
|
||||
// Best-effort — a failed read leaves the panel empty.
|
||||
if let Ok(relationships) = database.read_all_relationships().await {
|
||||
let _ = event_tx
|
||||
.send(AppEvent::RelationshipsRefreshed(relationships))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `SchemaCache` snapshot from the live database.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2589
|
||||
assertion_line: 2679
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -19,9 +19,9 @@ expression: snapshot
|
||||
│ ││ │
|
||||
│ │╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
│ │╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` for a list │
|
||||
│ ││ │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2789
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
│Customers ││ │
|
||||
│Orders ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │
|
||||
│ Orders.customer_id ││ │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user