DSL parser, async DB worker, types, history, metadata, polish

Track 1 implementation plus polish round.

Parser (chumsky):
- Grammar-based DSL producing a typed Command AST.
- create table X with pk [name:type[,name:type...]] supports
  arbitrary names, any user type, compound PKs natively. Bare
  form errors with a friendly hint pointing at `with pk`.
- add column to table X: Name (type); drop table X.
- Required clauses use keyword grammar; -- reserved for opt-in
  flags (ADR-0009). Custom Rich reasons preferred when surfacing
  chumsky errors so unknown-type messages list valid alternatives.

Database (ADR-0010, ADR-0012):
- rusqlite + STRICT tables + foreign_keys=ON.
- Dedicated worker thread; mpsc Request inbox, oneshot replies.
- Typed DbError with friendly_message() hook for H1.
- Internal __rdbms_playground_columns metadata table preserves
  user-facing types across schema reads, atomically maintained
  alongside DDL via Connection transactions. list_tables hides
  it via the new __rdbms_ internal-table convention.

Types (ADR-0005, ADR-0011):
- All ten user-facing types: text, int, real, decimal, bool,
  date, datetime, blob, serial, shortid.
- Type::fk_target_type() for FK-side column-type rule
  (Serial->Int, ShortId->Text, others identity) -- foundation
  for the FK iteration.

App / Runtime / UI:
- update() stays pure-sync; runtime dispatches DSL via spawned
  tasks, results post back as AppEvent::Dsl*.
- Items panel renders live tables list; output panel shows the
  user-facing structure of the current table after each DDL.
- In-memory command history (Up/Down, draft preservation,
  consecutive-duplicate dedup) -- I2 partial.
- Mouse capture removed; terminal native text selection
  restored (toggle approach revisited when scroll/click
  features land).

Docs:
- ADRs 0009 (DSL syntax conventions), 0010 (DB worker),
  0011 (FK type compat), 0012 (internal metadata table).
- requirements.md progress notes; new V4 entry for the
  scrollable session-log + inline rich rendering + Markdown
  export direction.

Tests: 103 passing (91 lib + 12 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
claude@clouddev1
2026-05-07 13:32:19 +00:00
parent 25a0f1260f
commit c1e52920eb
21 changed files with 3186 additions and 120 deletions
+91 -8
View File
@@ -32,7 +32,7 @@ pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(theme, frame, columns[0]);
render_items_panel(app, theme, frame, columns[0]);
render_right_column(app, theme, frame, columns[1]);
render_status_bar(app, theme, frame, outer[1]);
}
@@ -57,7 +57,7 @@ fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(block, area);
}
fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -70,13 +70,39 @@ fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let placeholder = Paragraph::new(Line::from(Span::styled(
"(none yet)",
Style::default().fg(theme.muted).add_modifier(Modifier::ITALIC),
)))
.block(block);
if app.tables.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
"(none yet)",
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::ITALIC),
)))
.block(block);
frame.render_widget(placeholder, area);
return;
}
frame.render_widget(placeholder, area);
let highlight = app
.current_table
.as_ref()
.map(|t| t.name.as_str())
.unwrap_or_default();
let lines: Vec<Line<'_>> = app
.tables
.iter()
.map(|name| {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
Line::from(Span::styled(name.as_str(), style))
})
.collect();
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
@@ -300,4 +326,61 @@ mod tests {
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
}
#[test]
fn populated_with_table_snapshot() {
// Items panel lists tables; output panel shows the
// structure of the current table.
use crate::app::{OutputKind, OutputLine};
use crate::db::{ColumnDescription, TableDescription};
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
use crate::dsl::Type;
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
ColumnDescription {
name: "id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
},
ColumnDescription {
name: "Name".to_string(),
user_type: Some(Type::Text),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
},
],
};
app.current_table = Some(desc);
// Mirror what the App writes when a DSL command succeeds.
app.output.push_back(OutputLine {
text: "[ok] create table Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " id serial [PK]".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " Name text".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
}