diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index dd90d36..aed79a2 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -211,17 +211,28 @@ holds two stacked panels (DB4) instead of one. The panel needs the full `RelationshipSchema` (name, parent/child tables, list-based columns, on-delete/on-update actions) that the `show -relationship` path already fetches. **`SchemaCache` is *extended*, not -retyped:** its existing `relationships: Vec` is left as-is -(`completion.rs` borrows it as `&Vec` via -`IdentSource::Relationships` for relationship-name completion, and -several test fixtures construct it) and a **new field -`relationship_details: Vec`** is added alongside, -populated by the same cache refresh that runs on schema change (the -refresh is taught to query relationship detail, which today it does not -— it only lists names). Retyping the existing field would break the -completion borrow and the fixtures; adding a field is the -zero-ripple change. +relationship` path already fetches. + +**Data home — `App`, not `SchemaCache` (revised at implementation, +2026-06-10).** The design first proposed an additive +`SchemaCache.relationship_details: Vec` field. +Implementation revised this to a **parallel `App.relationships: +Vec`** field for two reasons: (1) `SchemaCache` is +*walker/completion-facing* — it needs only relationship **names** +(unchanged in `SchemaCache.relationships`, still borrowed as +`&Vec` by `IdentSource::Relationships`); the full records are +**UI-only**, so `App` is the architecturally correct home, mirroring +`app.tables` (which the items panel already reads alongside the cache). +(2) Adding a field to `SchemaCache` would force edits to ~23 full +struct literals across the test suite, whereas `App` gains one field. +The /runda guard it answered — *don't break completion by retyping +`relationships`* — is fully honoured either way. Delivery: a worker +`Request::ReadAllRelationships` (→ `Database::read_all_relationships`, +returning `Vec` via the existing +`read_all_relationships(conn)`); the runtime's `refresh_schema_cache` +posts a new `AppEvent::RelationshipsRefreshed` alongside +`SchemaCacheRefreshed`, and the `App` stores it. No behavioural +difference from the original design. The panel has **two display states** keyed off focus (DC2): @@ -357,10 +368,11 @@ silently edit an invisible buffer. - Per-panel scroll offsets for the Tables and Relationships panels, each clamped against a renderer-reported viewport (DC3), mirroring `output_scroll` / `note_output_viewport`. -- `SchemaCache` gains **`relationship_details: Vec`** - (DB2) — *additive*; the existing `relationships: Vec` (names, - used by `completion.rs` `IdentSource::Relationships`) is unchanged. The - cache refresh is extended to populate the new field. +- **`App.relationships: Vec`** (DB2) — the full + relationship records for the sidebar panel, delivered by + `AppEvent::RelationshipsRefreshed` from the runtime's schema refresh. + `SchemaCache.relationships: Vec` (names, for completion) is + unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.) **Render-time derived** (pure functions of `frame.area()` + cached counts — *not* stored fields; this keeps the pure-render invariant and @@ -458,11 +470,11 @@ Phase A: scrolls. Phase B: -- **Schema-cache enrichment:** after each schema mutation the cache - carries full `relationship_details` (name, parent/child, columns, - actions) *and* the existing `relationships` names; `completion.rs` - `IdentSource::Relationships` still resolves names (the additive field - did not disturb it). +- **Relationship data path:** `Database::read_all_relationships` + returns full records through the worker thread (integration test, real + DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates + `App.relationships`; `SchemaCache.relationships` names are undisturbed + (completion still resolves them). - **Relationships panel render (Tier-2):** empty → a single `None` line at the 5-row floor; the unfocused narrow format (name + arrow-break, ellipsis past inner width); a compound endpoint pair arrow-breaks diff --git a/docs/adr/README.md b/docs/adr/README.md index 7d92890..728eca5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -51,4 +51,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships -- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted 2026-06-10; implementation pending, phased A→B→C** (closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). `SchemaCache` is **extended additively** with `relationship_details: Vec` (the existing names-only `relationships: Vec` is kept for completion); the two left panels split vertically with the relationships panel floored at 5 rows ("None" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) +- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted 2026-06-10; implementation pending, phased A→B→C** (closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) diff --git a/src/app.rs b/src/app.rs index 189b367..2e8e6f0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -252,6 +252,12 @@ pub struct App { /// [`App::input_validity_verdict`] once typing pauses. pub input_indicator: Option, pub tables: Vec, + /// 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, /// Last successfully described table, shown in the output /// pane until the next DDL operation. pub current_table: Option, @@ -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 diff --git a/src/db.rs b/src/db.rs index 48a85e0..562b8d8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -837,6 +837,13 @@ enum Request { source: crate::dsl::grammar::IdentSource, reply: oneshot::Sender, 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, 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, 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 diff --git a/src/event.rs b/src/event.rs index 293dacf..51b2be2 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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), /// A persistence failure occurred (ADR-0015 §8). The /// application surfaces a fatal banner and exits cleanly so /// the message remains above the shell prompt. diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index f8b256c..389a22a 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -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", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 2ebed9e..c38402b 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -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). diff --git a/src/runtime.rs b/src/runtime.rs index 488020c..65d5f62 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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. diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index aac58e2..012b295 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -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 │ │ ││ │ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap new file mode 100644 index 0000000..3840ae1 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap @@ -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 diff --git a/src/ui.rs b/src/ui.rs index 5642d48..cdd6adc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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> = 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); + } } diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs index df0b6d1..972a8b3 100644 --- a/tests/it/m2n.rs +++ b/tests/it/m2n.rs @@ -416,3 +416,40 @@ fn pk_less_parent_is_refused() { assert!(format!("{err}").contains("no primary key"), "got: {err}"); }); } + +/// ADR-0046 DB2: the worker's `read_all_relationships` returns full +/// schema records (name, parent/child tables + columns, actions) — the +/// data source for the sidebar relationships panel. Exercised through +/// the real worker thread after an m:n junction creates two of them. +#[test] +fn read_all_relationships_returns_the_junction_relationships() { + let (_project, db, _dir) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect("create m:n"); + + let rels = db + .read_all_relationships() + .await + .expect("read all relationships"); + assert_eq!( + rels.len(), + 2, + "the m:n junction creates two relationships: {rels:?}" + ); + // Both have the junction (Students_Courses) as their child. + for r in &rels { + assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}"); + } + // One points back to each parent. + let parents: std::collections::BTreeSet<&str> = + rels.iter().map(|r| r.parent_table.as_str()).collect(); + assert!( + parents.contains("Students") && parents.contains("Courses"), + "one relationship per parent: {rels:?}" + ); + }); +}