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:
+109
-19
@@ -3,18 +3,17 @@
|
||||
//! A blocking task reads crossterm events and forwards them onto
|
||||
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
|
||||
//! feeds them to `App::update`, enacts any returned `Action`s,
|
||||
//! and redraws the terminal. Future async work (query execution,
|
||||
//! snapshotting, auto-save) joins the same channel as additional
|
||||
//! producers, which is why we set the architecture up this way
|
||||
//! from day one.
|
||||
//! and redraws the terminal. DSL execution is dispatched onto
|
||||
//! the database worker (see `db::Database`), and its result is
|
||||
//! posted back as a new `AppEvent`. Future async work (snapshot
|
||||
//! capture, auto-save) joins the same event channel as
|
||||
//! additional producers.
|
||||
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{
|
||||
DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream,
|
||||
};
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
@@ -27,6 +26,8 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::app::App;
|
||||
use crate::db::{Database, DbError, TableDescription};
|
||||
use crate::dsl::Command;
|
||||
use crate::event::AppEvent;
|
||||
use crate::theme::Theme;
|
||||
use crate::ui;
|
||||
@@ -37,8 +38,12 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
|
||||
/// Run the application until a `Quit` action is enacted or the
|
||||
/// terminal closes.
|
||||
pub async fn run(theme: Theme) -> Result<()> {
|
||||
// For this iteration, every session uses a fresh in-memory
|
||||
// database. Track 2 (project storage) wires up file-backed
|
||||
// databases with proper lifecycle management.
|
||||
let database = Database::open(":memory:").context("open database")?;
|
||||
let mut terminal = setup_terminal().context("setup terminal")?;
|
||||
let result = run_loop(&mut terminal, theme).await;
|
||||
let result = run_loop(&mut terminal, theme, database).await;
|
||||
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||
// Teardown failures should not mask the primary error.
|
||||
warn!(error = %e, "terminal teardown failed");
|
||||
@@ -49,13 +54,19 @@ pub async fn run(theme: Theme) -> Result<()> {
|
||||
async fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
theme: Theme,
|
||||
database: Database,
|
||||
) -> Result<()> {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
let reader_handle = spawn_event_reader(event_tx);
|
||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
// Initial draw before any events arrive.
|
||||
// Seed the table list with whatever the database currently
|
||||
// shows. For a fresh in-memory DB this is empty, but doing
|
||||
// it explicitly means file-backed databases (track 2) will
|
||||
// show their tables on launch without changes here.
|
||||
seed_initial_tables(&database, &event_tx).await;
|
||||
|
||||
terminal
|
||||
.draw(|f| ui::render(&app, &theme, f))
|
||||
.context("initial draw")?;
|
||||
@@ -70,6 +81,9 @@ async fn run_loop(
|
||||
debug!("quit action received");
|
||||
should_quit = true;
|
||||
}
|
||||
Action::ExecuteDsl(command) => {
|
||||
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command);
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal
|
||||
@@ -80,13 +94,89 @@ async fn run_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// Give the reader a moment to notice the dropped sender.
|
||||
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
|
||||
|
||||
info!("event loop exited");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
|
||||
match database.list_tables().await {
|
||||
Ok(tables) => {
|
||||
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to seed initial table list");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a task that runs a DSL command against the database
|
||||
/// and forwards the result back as an `AppEvent`.
|
||||
fn spawn_dsl_dispatch(
|
||||
database: Database,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
command: Command,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let outcome = execute_command(&database, command.clone()).await;
|
||||
let event = match outcome {
|
||||
Ok(description) => AppEvent::DslSucceeded {
|
||||
command: command.clone(),
|
||||
description,
|
||||
},
|
||||
Err(error) => AppEvent::DslFailed {
|
||||
command: command.clone(),
|
||||
error,
|
||||
},
|
||||
};
|
||||
if event_tx.send(event).await.is_err() {
|
||||
return;
|
||||
}
|
||||
// Refresh the table list after every DDL operation so
|
||||
// the items panel reflects reality. A failed list_tables
|
||||
// here is logged but not surfaced to the user — they
|
||||
// already saw the primary outcome.
|
||||
match database.list_tables().await {
|
||||
Ok(tables) => {
|
||||
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||
}
|
||||
Err(e) => warn!(error = %e, "post-DDL list_tables failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn execute_command(
|
||||
database: &Database,
|
||||
command: Command,
|
||||
) -> Result<Option<TableDescription>, String> {
|
||||
match command {
|
||||
Command::CreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
} => database
|
||||
.create_table(name, columns, primary_key)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(friendly),
|
||||
Command::DropTable { name } => database
|
||||
.drop_table(name)
|
||||
.await
|
||||
.map(|()| None)
|
||||
.map_err(friendly),
|
||||
Command::AddColumn { table, column, ty } => database
|
||||
.add_column(table, column, ty)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(friendly),
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly(err: DbError) -> String {
|
||||
err.friendly_message()
|
||||
}
|
||||
|
||||
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut stream = EventStream::new();
|
||||
@@ -118,8 +208,12 @@ fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()>
|
||||
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
enable_raw_mode().context("enable raw mode")?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
|
||||
.context("enter alternate screen")?;
|
||||
// Mouse capture is intentionally NOT enabled: it would prevent the
|
||||
// host terminal's native text selection (the cost of capturing every
|
||||
// mouse event), which we don't currently use for anything in-app.
|
||||
// If we ever want click-to-select panes or scroll wheel handling,
|
||||
// we'll need a different strategy than blanket capture.
|
||||
execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let terminal = Terminal::new(backend).context("construct terminal")?;
|
||||
Ok(terminal)
|
||||
@@ -129,12 +223,8 @@ fn teardown_terminal(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
disable_raw_mode().context("disable raw mode")?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)
|
||||
.context("leave alternate screen")?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)
|
||||
.context("leave alternate screen")?;
|
||||
terminal.show_cursor().context("show cursor")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user