601d3b6c51
Replaces the in-memory database with an on-disk project. Startup either opens a project at the positional CLI path (L1) or creates an auto-named temp project (<YYYYMMDD>-<word>-<word>-<word>) under the OS-standard data directory or a --data-dir override. The new project::Project type owns the directory skeleton and a PID+hostname lock file with stale-lock takeover via sysinfo. The status bar now shows "Project: <Display Name>", derived by a small kebab/snake/camel prettifier. Per-command persistence to YAML/CSV/history.log is NOT yet wired -- that's Iteration 2; for now playground.db carries the state across quits. Tests: 257 passing (231 lib + 9 new integration + 17 existing), 0 failing, 0 skipped. Clippy clean with nursery lints.
1438 lines
48 KiB
Rust
1438 lines
48 KiB
Rust
//! Application state and the single `update` entry point.
|
|
//!
|
|
//! `update` is pure with respect to the runtime: it mutates
|
|
//! state in place and returns a list of `Action`s. Side effects
|
|
//! (DB execution, quit, etc.) live in the runtime. This keeps
|
|
//! every behaviour drivable from synthetic events in tests,
|
|
//! which is what makes ADR-0008's Tier 1/3 testing tractable.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
use tracing::{trace, warn};
|
|
|
|
use crate::action::Action;
|
|
use crate::db::{
|
|
CascadeEffect, DataResult, DeleteResult, InsertResult, TableDescription, UpdateResult,
|
|
};
|
|
use crate::dsl::{Command, ParseError, parse_command};
|
|
use crate::event::AppEvent;
|
|
use crate::mode::Mode;
|
|
|
|
/// Maximum number of output lines kept in the rolling buffer.
|
|
const OUTPUT_CAPACITY: usize = 1000;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum OutputKind {
|
|
Echo,
|
|
System,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OutputLine {
|
|
pub text: String,
|
|
pub kind: OutputKind,
|
|
pub mode_at_submission: Mode,
|
|
}
|
|
|
|
/// What mode the next submission would be evaluated in.
|
|
///
|
|
/// Derived from the persistent mode and the current input
|
|
/// buffer. The UI uses this to give immediate visual feedback
|
|
/// for the `:` one-shot escape: the moment a leading `:` is
|
|
/// typed in simple mode, the prompt flips to advanced styling,
|
|
/// and reverts as soon as the `:` is deleted.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EffectiveMode {
|
|
Simple,
|
|
AdvancedPersistent,
|
|
AdvancedOneShot,
|
|
}
|
|
|
|
impl EffectiveMode {
|
|
#[must_use]
|
|
pub const fn is_advanced(self) -> bool {
|
|
matches!(self, Self::AdvancedPersistent | Self::AdvancedOneShot)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct App {
|
|
pub mode: Mode,
|
|
pub input: String,
|
|
/// Byte offset into `input` where the next character will be
|
|
/// inserted. Always lies on a UTF-8 character boundary.
|
|
pub input_cursor: usize,
|
|
pub output: VecDeque<OutputLine>,
|
|
pub hint: Option<String>,
|
|
pub tables: Vec<String>,
|
|
/// Last successfully described table, shown in the output
|
|
/// pane until the next DDL operation.
|
|
pub current_table: Option<TableDescription>,
|
|
/// In-memory history of submitted lines, oldest first.
|
|
/// Persistent history across sessions (I2 second half) lands
|
|
/// when track 2's project storage is in place.
|
|
pub history: Vec<String>,
|
|
/// Position within `history` while navigating with Up/Down.
|
|
/// `None` means "not navigating; `input` is the user's
|
|
/// in-progress draft."
|
|
history_cursor: Option<usize>,
|
|
/// Snapshot of the user's in-progress draft taken when they
|
|
/// start navigating history, restored if they navigate back
|
|
/// past the most recent entry.
|
|
history_draft: Option<String>,
|
|
/// Number of lines from the bottom we've scrolled up. `0`
|
|
/// means "showing the most recent lines"; positive values
|
|
/// reveal older lines. Reset to `0` whenever a new output
|
|
/// line is appended so newly-arrived results are always
|
|
/// visible after a command. The full V4 session-log spec
|
|
/// supersedes this; we ship a minimal subset now to address
|
|
/// the immediate "ran out of space" UX problem.
|
|
pub output_scroll: usize,
|
|
/// The most recent visible-row count of the output panel,
|
|
/// reported by the renderer. Used to cap `output_scroll` —
|
|
/// without this, scrolling past `len - visible` would slide
|
|
/// the visible window off the top of the buffer and shrink
|
|
/// what the user sees.
|
|
pub last_output_visible: usize,
|
|
/// The most recent total *wrapped* row count of the output
|
|
/// panel — counted in display rows after wrapping, not in
|
|
/// logical OutputLines. Required for accurate scroll capping
|
|
/// when long lines wrap to multiple display rows.
|
|
pub last_output_total_wrapped: usize,
|
|
/// Prettified display name of the currently-open project,
|
|
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
|
/// during very-early startup before the runtime has opened a
|
|
/// project; otherwise always populated.
|
|
pub project_name: Option<String>,
|
|
}
|
|
|
|
const PAGE_SCROLL_LINES: usize = 5;
|
|
|
|
const HISTORY_CAPACITY: usize = 1000;
|
|
|
|
impl Default for App {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
mode: Mode::Simple,
|
|
input: String::new(),
|
|
input_cursor: 0,
|
|
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
|
hint: None,
|
|
tables: Vec::new(),
|
|
current_table: None,
|
|
history: Vec::new(),
|
|
history_cursor: None,
|
|
history_draft: None,
|
|
output_scroll: 0,
|
|
last_output_visible: 0,
|
|
last_output_total_wrapped: 0,
|
|
project_name: None,
|
|
}
|
|
}
|
|
|
|
/// Called by the renderer with the current output-panel
|
|
/// dimensions (row count + total wrapped-row count for the
|
|
/// current buffer) so subsequent scroll input is capped
|
|
/// correctly. Without `total_wrapped`, scroll math would
|
|
/// incorrectly assume one logical line = one display row.
|
|
pub const fn note_output_viewport(&mut self, visible_rows: usize, total_wrapped_rows: usize) {
|
|
self.last_output_visible = visible_rows;
|
|
self.last_output_total_wrapped = total_wrapped_rows;
|
|
// If a previous PageUp drifted past the maximum useful
|
|
// scroll (e.g. the user kept paging up past the top),
|
|
// bring it back so the next PageDown is responsive.
|
|
let max = total_wrapped_rows.saturating_sub(visible_rows);
|
|
if self.output_scroll > max {
|
|
self.output_scroll = max;
|
|
}
|
|
}
|
|
|
|
/// Effective mode for the *next* submission, given the
|
|
/// persistent mode and the current input buffer. See
|
|
/// [`EffectiveMode`].
|
|
#[must_use]
|
|
pub fn effective_mode(&self) -> EffectiveMode {
|
|
match self.mode {
|
|
Mode::Advanced => EffectiveMode::AdvancedPersistent,
|
|
Mode::Simple if self.input.trim_start().starts_with(':') => {
|
|
EffectiveMode::AdvancedOneShot
|
|
}
|
|
Mode::Simple => EffectiveMode::Simple,
|
|
}
|
|
}
|
|
|
|
/// Process one event from the runtime, mutating state and
|
|
/// returning any actions for the runtime to enact.
|
|
pub fn update(&mut self, event: AppEvent) -> Vec<Action> {
|
|
match event {
|
|
AppEvent::Key(key) => self.handle_key(key),
|
|
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
|
|
AppEvent::DslSucceeded {
|
|
command,
|
|
description,
|
|
} => {
|
|
self.handle_dsl_success(&command, description);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslDataSucceeded { command, data } => {
|
|
self.handle_dsl_query_success(&command, &data);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslInsertSucceeded { command, result } => {
|
|
self.handle_dsl_insert_success(&command, &result);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslUpdateSucceeded { command, result } => {
|
|
self.handle_dsl_update_success(&command, &result);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslDeleteSucceeded { command, result } => {
|
|
self.handle_dsl_delete_success(&command, &result);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslFailed { command, error } => {
|
|
self.handle_dsl_failure(&command, &error);
|
|
Vec::new()
|
|
}
|
|
AppEvent::TablesRefreshed(tables) => {
|
|
trace!(count = tables.len(), "tables refreshed");
|
|
self.tables = tables;
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
|
// On Windows, key events fire for both Press and Release;
|
|
// honour only Press to avoid double-handling. Other
|
|
// platforms emit Press only, so this is a no-op there.
|
|
if key.kind != KeyEventKind::Press {
|
|
return Vec::new();
|
|
}
|
|
trace!(?key, "handle_key");
|
|
match (key.code, key.modifiers) {
|
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
|
(KeyCode::Enter, _) => self.submit(),
|
|
(KeyCode::Up, _) => {
|
|
self.history_back();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::Down, _) => {
|
|
self.history_forward();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::Left, _) => {
|
|
self.cursor_left();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::Right, _) => {
|
|
self.cursor_right();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::Home, _) => {
|
|
self.input_cursor = 0;
|
|
Vec::new()
|
|
}
|
|
(KeyCode::End, _) => {
|
|
self.input_cursor = self.input.len();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::Backspace, _) => {
|
|
self.cancel_history_navigation();
|
|
self.delete_before_cursor();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::Delete, _) => {
|
|
self.cancel_history_navigation();
|
|
self.delete_at_cursor();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::PageUp, _) => {
|
|
self.scroll_output_up();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::PageDown, _) => {
|
|
self.scroll_output_down();
|
|
Vec::new()
|
|
}
|
|
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
|
self.cancel_history_navigation();
|
|
let was_empty = self.input.is_empty();
|
|
self.insert_at_cursor(c);
|
|
// Convenience: when `:` becomes the leading character in
|
|
// simple mode, auto-insert a space after it so the input
|
|
// reads ": foo" rather than ":foo". The trailing space is
|
|
// an ordinary character — backspace removes it normally.
|
|
if c == ':' && was_empty && self.mode == Mode::Simple {
|
|
self.insert_at_cursor(' ');
|
|
}
|
|
Vec::new()
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn cursor_left(&mut self) {
|
|
let mut idx = self.input_cursor;
|
|
while idx > 0 {
|
|
idx -= 1;
|
|
if self.input.is_char_boundary(idx) {
|
|
self.input_cursor = idx;
|
|
return;
|
|
}
|
|
}
|
|
self.input_cursor = 0;
|
|
}
|
|
|
|
fn cursor_right(&mut self) {
|
|
let mut idx = self.input_cursor;
|
|
while idx < self.input.len() {
|
|
idx += 1;
|
|
if self.input.is_char_boundary(idx) {
|
|
self.input_cursor = idx;
|
|
return;
|
|
}
|
|
}
|
|
self.input_cursor = self.input.len();
|
|
}
|
|
|
|
fn insert_at_cursor(&mut self, c: char) {
|
|
// Defensive clamp: callers (and tests) may mutate
|
|
// `input` directly; keep the cursor inside the buffer.
|
|
if self.input_cursor > self.input.len() {
|
|
self.input_cursor = self.input.len();
|
|
}
|
|
self.input.insert(self.input_cursor, c);
|
|
self.input_cursor += c.len_utf8();
|
|
}
|
|
|
|
fn delete_before_cursor(&mut self) {
|
|
if self.input_cursor == 0 {
|
|
return;
|
|
}
|
|
// Find the start of the previous character.
|
|
let mut idx = self.input_cursor - 1;
|
|
while !self.input.is_char_boundary(idx) {
|
|
idx -= 1;
|
|
}
|
|
self.input.replace_range(idx..self.input_cursor, "");
|
|
self.input_cursor = idx;
|
|
}
|
|
|
|
fn delete_at_cursor(&mut self) {
|
|
if self.input_cursor >= self.input.len() {
|
|
return;
|
|
}
|
|
// Find the end of the character at the cursor.
|
|
let mut idx = self.input_cursor + 1;
|
|
while idx < self.input.len() && !self.input.is_char_boundary(idx) {
|
|
idx += 1;
|
|
}
|
|
self.input.replace_range(self.input_cursor..idx, "");
|
|
}
|
|
|
|
/// Move backwards in history (towards older entries).
|
|
fn history_back(&mut self) {
|
|
if self.history.is_empty() {
|
|
return;
|
|
}
|
|
let next_index = match self.history_cursor {
|
|
None => {
|
|
// Starting navigation: save the current draft so the
|
|
// user can return to it.
|
|
self.history_draft = Some(self.input.clone());
|
|
self.history.len() - 1
|
|
}
|
|
Some(0) => 0,
|
|
Some(i) => i - 1,
|
|
};
|
|
self.history_cursor = Some(next_index);
|
|
self.input = self.history[next_index].clone();
|
|
self.input_cursor = self.input.len();
|
|
}
|
|
|
|
/// Move forwards in history (towards newer entries; eventually
|
|
/// returning to the user's saved draft).
|
|
fn history_forward(&mut self) {
|
|
let Some(i) = self.history_cursor else {
|
|
return;
|
|
};
|
|
if i + 1 < self.history.len() {
|
|
self.history_cursor = Some(i + 1);
|
|
self.input = self.history[i + 1].clone();
|
|
} else {
|
|
// Past the most recent entry — restore the draft and
|
|
// exit navigation mode.
|
|
self.history_cursor = None;
|
|
self.input = self.history_draft.take().unwrap_or_default();
|
|
}
|
|
self.input_cursor = self.input.len();
|
|
}
|
|
|
|
fn cancel_history_navigation(&mut self) {
|
|
self.history_cursor = None;
|
|
// Drop the saved draft: the user has begun editing again,
|
|
// so what's in `input` *is* the new draft.
|
|
self.history_draft = None;
|
|
}
|
|
|
|
fn push_history(&mut self, line: &str) {
|
|
// Skip empties and consecutive duplicates — the same
|
|
// trick most shells use to keep navigation pleasant.
|
|
if line.is_empty() {
|
|
return;
|
|
}
|
|
if self.history.last().map(String::as_str) == Some(line) {
|
|
return;
|
|
}
|
|
self.history.push(line.to_string());
|
|
while self.history.len() > HISTORY_CAPACITY {
|
|
self.history.remove(0);
|
|
}
|
|
self.history_cursor = None;
|
|
self.history_draft = None;
|
|
}
|
|
|
|
fn submit(&mut self) -> Vec<Action> {
|
|
let raw = std::mem::take(&mut self.input);
|
|
self.input_cursor = 0;
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
// Record the original (trimmed) line in history regardless
|
|
// of whether it parses, so users can recall and edit
|
|
// typo'd commands.
|
|
self.push_history(trimmed);
|
|
|
|
// `:` one-shot escape: in simple mode, a leading `:` means
|
|
// treat *this single submission* as advanced. The persistent
|
|
// mode is unchanged.
|
|
let (effective_mode, effective_input) =
|
|
if self.mode == Mode::Simple && trimmed.starts_with(':') {
|
|
(Mode::Advanced, trimmed[1..].trim().to_string())
|
|
} else {
|
|
(self.mode, trimmed.to_string())
|
|
};
|
|
|
|
if effective_input.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
// Canonical app-level commands recognised in both modes.
|
|
// The current iteration implements `quit` and `mode`;
|
|
// the rest of the canonical list lands in later iterations.
|
|
match effective_input.as_str() {
|
|
"quit" | "q" => return vec![Action::Quit],
|
|
other if other.starts_with("mode") => {
|
|
self.handle_mode_command(other);
|
|
return Vec::new();
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// For everything else: dispatch by effective mode.
|
|
match effective_mode {
|
|
Mode::Simple => self.dispatch_dsl(&effective_input, effective_mode),
|
|
Mode::Advanced => {
|
|
// SQL handling is not implemented yet; show a placeholder
|
|
// until the advanced-mode SQL path lands. Once it does,
|
|
// this branch parses with sqlparser-rs and dispatches
|
|
// analogously to the DSL path below.
|
|
self.note_system(format!(
|
|
"advanced mode SQL not implemented yet — echo: {effective_input}"
|
|
));
|
|
self.push_output(OutputLine {
|
|
text: effective_input,
|
|
kind: OutputKind::Echo,
|
|
mode_at_submission: effective_mode,
|
|
});
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
|
|
match parse_command(input) {
|
|
Ok(cmd) => {
|
|
self.push_output(OutputLine {
|
|
text: format!("running: {input}"),
|
|
kind: OutputKind::Echo,
|
|
mode_at_submission: submission_mode,
|
|
});
|
|
vec![Action::ExecuteDsl(cmd)]
|
|
}
|
|
Err(ParseError::Empty) => Vec::new(),
|
|
Err(err) => {
|
|
self.note_error(format!("parse error: {}", parse_error_message(&err)));
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
|
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
|
self.note_system(summary);
|
|
if let Some(desc) = description.as_ref() {
|
|
self.note_system(format!(" {}", desc.name));
|
|
for col in &desc.columns {
|
|
let pk = if col.primary_key { " [PK]" } else { "" };
|
|
let nn = if col.notnull { " NOT NULL" } else { "" };
|
|
// Prefer the user-facing type recovered from our
|
|
// metadata table; fall back to the SQLite type only
|
|
// if metadata is missing (only happens for tables we
|
|
// didn't create — not in the current flow).
|
|
let type_display = col
|
|
.user_type
|
|
.map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string());
|
|
self.note_system(format!(
|
|
" {} {}{}{}",
|
|
col.name, type_display, pk, nn
|
|
));
|
|
}
|
|
if !desc.outbound_relationships.is_empty() {
|
|
self.note_system(" References:");
|
|
for r in &desc.outbound_relationships {
|
|
self.note_system(format!(
|
|
" {} → {}.{} ({}, on delete {}, on update {})",
|
|
r.local_column,
|
|
r.other_table,
|
|
r.other_column,
|
|
r.name,
|
|
r.on_delete,
|
|
r.on_update,
|
|
));
|
|
}
|
|
}
|
|
if !desc.inbound_relationships.is_empty() {
|
|
self.note_system(" Referenced by:");
|
|
for r in &desc.inbound_relationships {
|
|
self.note_system(format!(
|
|
" {}.{} → {} ({}, on delete {}, on update {})",
|
|
r.other_table,
|
|
r.other_column,
|
|
r.local_column,
|
|
r.name,
|
|
r.on_delete,
|
|
r.on_update,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
self.current_table = description;
|
|
}
|
|
|
|
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
|
|
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
|
self.note_system(summary);
|
|
for line in render_data_view(data) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
|
|
self.note_system(format!(
|
|
"[ok] {} {}",
|
|
command.verb(),
|
|
command.display_subject()
|
|
));
|
|
self.note_system(format!(" {} row(s) inserted", result.rows_affected));
|
|
for line in render_data_view(&result.data) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
|
|
self.note_system(format!(
|
|
"[ok] {} {}",
|
|
command.verb(),
|
|
command.display_subject()
|
|
));
|
|
self.note_system(format!(" {} row(s) updated", result.rows_affected));
|
|
for line in render_data_view(&result.data) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) {
|
|
self.note_system(format!(
|
|
"[ok] {} {}",
|
|
command.verb(),
|
|
command.display_subject()
|
|
));
|
|
self.note_system(format!(" {} row(s) deleted", result.rows_affected));
|
|
for effect in &result.cascade {
|
|
self.note_system(render_cascade_effect(effect));
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
|
|
warn!(verb = command.verb(), error, "dsl command failed");
|
|
// Wrap the command portion in quotes so the message
|
|
// reads cleanly: "...failed: <reason>" rather than the
|
|
// command running into "failed: ..." with no break.
|
|
self.note_error(format!(
|
|
"\"{} {}\" failed: {error}",
|
|
command.verb(),
|
|
command.display_subject()
|
|
));
|
|
}
|
|
|
|
fn handle_mode_command(&mut self, raw: &str) {
|
|
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
|
|
match arg {
|
|
"simple" => {
|
|
self.mode = Mode::Simple;
|
|
self.note_system("mode: simple");
|
|
}
|
|
"advanced" => {
|
|
self.mode = Mode::Advanced;
|
|
self.note_system("mode: advanced");
|
|
}
|
|
"" => self.note_error("usage: mode simple | mode advanced"),
|
|
other => self.note_error(format!(
|
|
"unknown mode '{other}' (expected 'simple' or 'advanced')"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn note_system(&mut self, text: impl Into<String>) {
|
|
self.push_multiline(text.into(), OutputKind::System);
|
|
}
|
|
|
|
fn note_error(&mut self, text: impl Into<String>) {
|
|
self.push_multiline(text.into(), OutputKind::Error);
|
|
}
|
|
|
|
/// Push possibly-multi-line `text` as a sequence of single-line
|
|
/// `OutputLine`s. Keeping one display row per `OutputLine` is
|
|
/// what makes the scroll-position math (line count = display
|
|
/// rows) accurate; the renderer therefore truncates rather
|
|
/// than wraps long lines.
|
|
fn push_multiline(&mut self, text: String, kind: OutputKind) {
|
|
if text.is_empty() {
|
|
self.push_output(OutputLine {
|
|
text,
|
|
kind,
|
|
mode_at_submission: self.mode,
|
|
});
|
|
return;
|
|
}
|
|
for line in text.split('\n') {
|
|
self.push_output(OutputLine {
|
|
text: line.to_string(),
|
|
kind,
|
|
mode_at_submission: self.mode,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn push_output(&mut self, line: OutputLine) {
|
|
self.output.push_back(line);
|
|
while self.output.len() > OUTPUT_CAPACITY {
|
|
self.output.pop_front();
|
|
}
|
|
// Any new line resets the scroll so freshly-arrived
|
|
// output is always visible. The user can PageUp again
|
|
// to inspect history.
|
|
self.output_scroll = 0;
|
|
}
|
|
|
|
fn scroll_output_up(&mut self) {
|
|
// Cap at `total_wrapped - visible` (display rows, not
|
|
// logical lines) so the topmost visible chunk is the
|
|
// first `visible` rendered rows; going past that would
|
|
// shrink the view by sliding the window off the top.
|
|
let max = self
|
|
.last_output_total_wrapped
|
|
.saturating_sub(self.last_output_visible.max(1));
|
|
self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max);
|
|
}
|
|
|
|
const fn scroll_output_down(&mut self) {
|
|
self.output_scroll = self.output_scroll.saturating_sub(PAGE_SCROLL_LINES);
|
|
}
|
|
}
|
|
|
|
fn parse_error_message(err: &ParseError) -> String {
|
|
match err {
|
|
ParseError::Invalid { message, .. } => message.clone(),
|
|
ParseError::Empty => "empty input".to_string(),
|
|
}
|
|
}
|
|
|
|
fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
|
use crate::dsl::ReferentialAction;
|
|
let what = match effect.action {
|
|
ReferentialAction::Cascade => "deleted",
|
|
ReferentialAction::SetNull => "had FK set to null",
|
|
ReferentialAction::Restrict | ReferentialAction::NoAction => "blocked",
|
|
};
|
|
format!(
|
|
" related: {} row(s) {} in `{}` for relationship `{}` (on delete {})",
|
|
effect.rows_changed,
|
|
what,
|
|
effect.child_table,
|
|
effect.relationship_name,
|
|
effect.action,
|
|
)
|
|
}
|
|
|
|
/// Render a data result as a sequence of aligned-column text
|
|
/// lines suitable for the output panel. Pretty box-drawing
|
|
/// rendering is V4 territory; this version uses simple
|
|
/// pipe-and-dash separators.
|
|
fn render_data_view(data: &DataResult) -> Vec<String> {
|
|
let header = data.columns.clone();
|
|
let body: Vec<Vec<String>> = data
|
|
.rows
|
|
.iter()
|
|
.map(|row| {
|
|
row.iter()
|
|
.map(|cell| {
|
|
cell.as_ref()
|
|
.map_or_else(|| "(null)".to_string(), Clone::clone)
|
|
})
|
|
.collect()
|
|
})
|
|
.collect();
|
|
|
|
// Column widths = max(header, all cells) per column.
|
|
let mut widths: Vec<usize> = header.iter().map(String::len).collect();
|
|
for row in &body {
|
|
for (i, cell) in row.iter().enumerate() {
|
|
if i < widths.len() && cell.chars().count() > widths[i] {
|
|
widths[i] = cell.chars().count();
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
|
|
out.push(format!(" {}", join_padded(&header, &widths)));
|
|
out.push(format!(" {}", separator_row(&widths)));
|
|
if body.is_empty() {
|
|
out.push(" (no rows)".to_string());
|
|
} else {
|
|
for row in &body {
|
|
out.push(format!(" {}", join_padded(row, &widths)));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn join_padded(cells: &[String], widths: &[usize]) -> String {
|
|
let mut s = String::new();
|
|
for (i, cell) in cells.iter().enumerate() {
|
|
if i > 0 {
|
|
s.push_str(" | ");
|
|
}
|
|
let w = widths.get(i).copied().unwrap_or(0);
|
|
s.push_str(cell);
|
|
let pad = w.saturating_sub(cell.chars().count());
|
|
for _ in 0..pad {
|
|
s.push(' ');
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
fn separator_row(widths: &[usize]) -> String {
|
|
let mut s = String::new();
|
|
for (i, w) in widths.iter().enumerate() {
|
|
if i > 0 {
|
|
s.push_str("-+-");
|
|
}
|
|
for _ in 0..*w {
|
|
s.push('-');
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::ColumnDescription;
|
|
use crate::dsl::Type;
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn key(code: KeyCode) -> AppEvent {
|
|
AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
|
|
}
|
|
|
|
fn key_mod(code: KeyCode, mods: KeyModifiers) -> AppEvent {
|
|
AppEvent::Key(KeyEvent::new(code, mods))
|
|
}
|
|
|
|
fn type_str(app: &mut App, s: &str) {
|
|
for c in s.chars() {
|
|
app.update(key(KeyCode::Char(c)));
|
|
}
|
|
}
|
|
|
|
fn submit(app: &mut App) -> Vec<Action> {
|
|
app.update(key(KeyCode::Enter))
|
|
}
|
|
|
|
fn sample_description(name: &str) -> TableDescription {
|
|
TableDescription {
|
|
name: name.to_string(),
|
|
columns: vec![ColumnDescription {
|
|
name: "id".to_string(),
|
|
user_type: Some(Type::Serial),
|
|
sqlite_type: "INTEGER".to_string(),
|
|
notnull: false,
|
|
primary_key: true,
|
|
}],
|
|
outbound_relationships: Vec::new(),
|
|
inbound_relationships: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typing_accumulates_in_input_buffer() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
assert_eq!(app.input, "hello");
|
|
assert!(app.output.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_removes_last_char() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "abc");
|
|
app.update(key(KeyCode::Backspace));
|
|
assert_eq!(app.input, "ab");
|
|
}
|
|
|
|
#[test]
|
|
fn valid_dsl_in_simple_mode_emits_execute_action() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "create table Customers with pk");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(
|
|
actions,
|
|
vec![Action::ExecuteDsl(Command::CreateTable {
|
|
name: "Customers".to_string(),
|
|
columns: vec![crate::dsl::ColumnSpec {
|
|
name: "id".to_string(),
|
|
ty: Type::Serial,
|
|
}],
|
|
primary_key: vec!["id".to_string()],
|
|
})]
|
|
);
|
|
// The input is echoed back as a "running:" notice so the
|
|
// user sees something happened while the DB worker runs.
|
|
assert!(!app.output.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn bare_create_table_emits_friendly_parse_error() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "create table Customers");
|
|
let actions = submit(&mut app);
|
|
assert!(actions.is_empty());
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.kind, OutputKind::Error);
|
|
assert!(
|
|
last.text.contains("with pk"),
|
|
"error should mention `with pk`: {}",
|
|
last.text
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_dsl_in_simple_mode_produces_parse_error_in_output() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "frobulate widgets");
|
|
let actions = submit(&mut app);
|
|
assert!(actions.is_empty());
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.kind, OutputKind::Error);
|
|
assert!(last.text.starts_with("parse error"));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_in_advanced_mode_echoes_with_advanced_tag() {
|
|
let mut app = App::new();
|
|
app.mode = Mode::Advanced;
|
|
type_str(&mut app, "select 1");
|
|
submit(&mut app);
|
|
// We expect a placeholder system line plus the echoed line.
|
|
let echoed = app
|
|
.output
|
|
.iter()
|
|
.rfind(|l| l.kind == OutputKind::Echo)
|
|
.unwrap();
|
|
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
|
|
assert_eq!(echoed.text, "select 1");
|
|
}
|
|
|
|
#[test]
|
|
fn mode_command_switches_persistently() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "mode advanced");
|
|
submit(&mut app);
|
|
assert_eq!(app.mode, Mode::Advanced);
|
|
type_str(&mut app, "mode simple");
|
|
submit(&mut app);
|
|
assert_eq!(app.mode, Mode::Simple);
|
|
}
|
|
|
|
#[test]
|
|
fn mode_command_with_unknown_arg_errors() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "mode sideways");
|
|
submit(&mut app);
|
|
assert_eq!(app.mode, Mode::Simple);
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.kind, OutputKind::Error);
|
|
assert!(last.text.contains("unknown mode"));
|
|
}
|
|
|
|
#[test]
|
|
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, ":select 1");
|
|
submit(&mut app);
|
|
// The persistent mode is unchanged.
|
|
assert_eq!(app.mode, Mode::Simple);
|
|
// The advanced echo line is present.
|
|
let echoed = app
|
|
.output
|
|
.iter()
|
|
.rfind(|l| l.kind == OutputKind::Echo)
|
|
.unwrap();
|
|
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
|
|
assert_eq!(echoed.text, "select 1");
|
|
}
|
|
|
|
#[test]
|
|
fn quit_command_returns_quit_action() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "quit");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(actions, vec![Action::Quit]);
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_c_returns_quit_action() {
|
|
let mut app = App::new();
|
|
let actions = app.update(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
|
assert_eq!(actions, vec![Action::Quit]);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_submission_is_a_noop() {
|
|
let mut app = App::new();
|
|
let actions = submit(&mut app);
|
|
assert!(actions.is_empty());
|
|
assert!(app.output.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn output_buffer_is_capped() {
|
|
let mut app = App::new();
|
|
for i in 0..(OUTPUT_CAPACITY + 50) {
|
|
app.note_system(format!("line{i}"));
|
|
}
|
|
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
|
|
// Oldest entries were dropped.
|
|
assert!(app.output.front().unwrap().text.starts_with("line50"));
|
|
}
|
|
|
|
#[test]
|
|
fn effective_mode_reflects_persistent_mode_when_no_input() {
|
|
let mut app = App::new();
|
|
assert_eq!(app.effective_mode(), EffectiveMode::Simple);
|
|
app.mode = Mode::Advanced;
|
|
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_mode_flips_to_one_shot_when_colon_typed_in_simple_mode() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, ":sel");
|
|
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
|
|
while !app.input.is_empty() {
|
|
app.update(key(KeyCode::Backspace));
|
|
}
|
|
assert_eq!(app.effective_mode(), EffectiveMode::Simple);
|
|
}
|
|
|
|
#[test]
|
|
fn typing_colon_first_in_simple_mode_auto_inserts_a_space() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, ":");
|
|
assert_eq!(app.input, ": ");
|
|
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
|
|
}
|
|
|
|
#[test]
|
|
fn typing_colon_after_other_chars_does_not_auto_insert_space() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "ab:");
|
|
assert_eq!(app.input, "ab:");
|
|
}
|
|
|
|
#[test]
|
|
fn typing_colon_in_advanced_mode_does_not_auto_insert_space() {
|
|
let mut app = App::new();
|
|
app.mode = Mode::Advanced;
|
|
type_str(&mut app, ":");
|
|
assert_eq!(app.input, ":");
|
|
}
|
|
|
|
#[test]
|
|
fn auto_inserted_space_can_be_removed_with_backspace() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, ":");
|
|
assert_eq!(app.input, ": ");
|
|
app.update(key(KeyCode::Backspace));
|
|
assert_eq!(app.input, ":");
|
|
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
|
|
}
|
|
|
|
#[test]
|
|
fn dsl_success_event_records_table_view_and_appends_summary() {
|
|
let mut app = App::new();
|
|
let cmd = Command::CreateTable {
|
|
name: "Customers".to_string(),
|
|
columns: vec![crate::dsl::ColumnSpec {
|
|
name: "id".to_string(),
|
|
ty: Type::Serial,
|
|
}],
|
|
primary_key: vec!["id".to_string()],
|
|
};
|
|
let desc = sample_description("Customers");
|
|
app.update(AppEvent::DslSucceeded {
|
|
command: cmd,
|
|
description: Some(desc.clone()),
|
|
});
|
|
assert_eq!(app.current_table, Some(desc));
|
|
let last = app.output.back().unwrap();
|
|
// Last line is the column row of the structure summary.
|
|
assert!(last.text.contains("id"));
|
|
// Earlier line is the [ok] header.
|
|
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
|
|
}
|
|
|
|
#[test]
|
|
fn dsl_failure_event_writes_error_with_friendly_message() {
|
|
let mut app = App::new();
|
|
let cmd = Command::DropTable {
|
|
name: "Ghost".to_string(),
|
|
};
|
|
app.update(AppEvent::DslFailed {
|
|
command: cmd,
|
|
error: "no such table: Ghost".to_string(),
|
|
});
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.kind, OutputKind::Error);
|
|
assert!(last.text.contains("Ghost"));
|
|
assert!(last.text.contains("no such table"));
|
|
}
|
|
|
|
#[test]
|
|
fn tables_refreshed_event_replaces_cached_list() {
|
|
let mut app = App::new();
|
|
app.update(AppEvent::TablesRefreshed(vec![
|
|
"A".to_string(),
|
|
"B".to_string(),
|
|
]));
|
|
assert_eq!(app.tables, vec!["A".to_string(), "B".to_string()]);
|
|
app.update(AppEvent::TablesRefreshed(vec!["C".to_string()]));
|
|
assert_eq!(app.tables, vec!["C".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn add_column_command_with_unknown_type_reports_parse_error() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "add column to table T: c (varchar)");
|
|
let actions = submit(&mut app);
|
|
assert!(actions.is_empty());
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.kind, OutputKind::Error);
|
|
assert!(last.text.contains("varchar"));
|
|
}
|
|
|
|
#[test]
|
|
fn drop_table_command_emits_execute_action() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop table T");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(
|
|
actions,
|
|
vec![Action::ExecuteDsl(Command::DropTable {
|
|
name: "T".to_string()
|
|
})]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typing_moves_cursor_to_end_of_input() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
assert_eq!(app.input, "hello");
|
|
assert_eq!(app.input_cursor, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn left_arrow_moves_cursor_back_one_char() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
app.update(key(KeyCode::Left));
|
|
assert_eq!(app.input_cursor, 4);
|
|
app.update(key(KeyCode::Left));
|
|
assert_eq!(app.input_cursor, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn left_arrow_at_zero_does_not_underflow() {
|
|
let mut app = App::new();
|
|
app.update(key(KeyCode::Left));
|
|
assert_eq!(app.input_cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn right_arrow_moves_cursor_forward() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
app.input_cursor = 0;
|
|
app.update(key(KeyCode::Right));
|
|
assert_eq!(app.input_cursor, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn home_and_end_jump_to_extremes() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
app.update(key(KeyCode::Home));
|
|
assert_eq!(app.input_cursor, 0);
|
|
app.update(key(KeyCode::End));
|
|
assert_eq!(app.input_cursor, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn typing_inserts_at_cursor_position() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
// Cursor between 'h' and 'e'.
|
|
app.input_cursor = 1;
|
|
type_str(&mut app, "X");
|
|
assert_eq!(app.input, "hXello");
|
|
assert_eq!(app.input_cursor, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_removes_char_before_cursor() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
// Cursor at end.
|
|
app.update(key(KeyCode::Backspace));
|
|
assert_eq!(app.input, "hell");
|
|
assert_eq!(app.input_cursor, 4);
|
|
|
|
// Cursor in the middle.
|
|
app.input_cursor = 2; // between 'e' and 'l'
|
|
app.update(key(KeyCode::Backspace));
|
|
assert_eq!(app.input, "hll");
|
|
assert_eq!(app.input_cursor, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_at_start_is_a_noop() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
app.input_cursor = 0;
|
|
app.update(key(KeyCode::Backspace));
|
|
assert_eq!(app.input, "hello");
|
|
assert_eq!(app.input_cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_removes_char_at_cursor() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
app.input_cursor = 1; // between 'h' and 'e'
|
|
app.update(key(KeyCode::Delete));
|
|
assert_eq!(app.input, "hllo");
|
|
assert_eq!(app.input_cursor, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_at_end_is_a_noop() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "hello");
|
|
app.update(key(KeyCode::Delete));
|
|
assert_eq!(app.input, "hello");
|
|
assert_eq!(app.input_cursor, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_handles_multibyte_chars() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "héllo"); // 'é' is 2 bytes
|
|
// input length is 6 bytes, 5 chars
|
|
assert_eq!(app.input.len(), 6);
|
|
assert_eq!(app.input_cursor, 6);
|
|
// Move left across the 2-byte char.
|
|
app.update(key(KeyCode::Left));
|
|
assert_eq!(app.input_cursor, 5);
|
|
app.update(key(KeyCode::Left));
|
|
assert_eq!(app.input_cursor, 4);
|
|
app.update(key(KeyCode::Left));
|
|
assert_eq!(app.input_cursor, 3);
|
|
app.update(key(KeyCode::Left));
|
|
// Now at the byte before 'é' — must skip the multi-byte char.
|
|
assert_eq!(app.input_cursor, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn submit_resets_cursor_to_zero() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop table T");
|
|
submit(&mut app);
|
|
assert_eq!(app.input_cursor, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn page_up_scrolls_output_back() {
|
|
let mut app = App::new();
|
|
for i in 0..30 {
|
|
app.note_system(format!("line{i}"));
|
|
}
|
|
// Simulate a render establishing 10 visible / 30 wrapped.
|
|
app.note_output_viewport(10, 30);
|
|
assert_eq!(app.output_scroll, 0);
|
|
app.update(key(KeyCode::PageUp));
|
|
assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES);
|
|
}
|
|
|
|
#[test]
|
|
fn page_down_scrolls_output_back_to_bottom() {
|
|
let mut app = App::new();
|
|
for i in 0..30 {
|
|
app.note_system(format!("line{i}"));
|
|
}
|
|
app.note_output_viewport(10, 30);
|
|
for _ in 0..3 {
|
|
app.update(key(KeyCode::PageUp));
|
|
}
|
|
assert!(app.output_scroll > 0);
|
|
for _ in 0..10 {
|
|
app.update(key(KeyCode::PageDown));
|
|
}
|
|
assert_eq!(app.output_scroll, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn new_output_resets_scroll_to_zero() {
|
|
let mut app = App::new();
|
|
for i in 0..30 {
|
|
app.note_system(format!("line{i}"));
|
|
}
|
|
app.note_output_viewport(10, 30);
|
|
app.update(key(KeyCode::PageUp));
|
|
assert!(app.output_scroll > 0);
|
|
// Any new output line snaps the scroll back to bottom so
|
|
// the user always sees the latest result after a command.
|
|
app.note_system("fresh");
|
|
assert_eq!(app.output_scroll, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn page_up_caps_at_top_of_buffer() {
|
|
let mut app = App::new();
|
|
app.note_system("only line");
|
|
// Many PageUps in a row should not push past the buffer.
|
|
for _ in 0..50 {
|
|
app.update(key(KeyCode::PageUp));
|
|
}
|
|
// With 1 line in the buffer, the maximum scroll is 0
|
|
// (since there's nothing older to reveal).
|
|
assert_eq!(app.output_scroll, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn page_up_at_top_of_buffer_does_not_shrink_visible_window() {
|
|
// Regression: extra PageUps past the top used to drift
|
|
// `output_scroll` higher than `len - visible`, which
|
|
// then made the rendered window slide off the top and
|
|
// appeared to "eat" lines from the bottom.
|
|
let mut app = App::new();
|
|
for i in 0..30 {
|
|
app.note_system(format!("line{i}"));
|
|
}
|
|
// Simulate a render reporting 10 visible rows over a
|
|
// 30-row wrapped buffer (every line fits in one row in
|
|
// this test).
|
|
app.note_output_viewport(10, 30);
|
|
// Page up many times — past the maximum useful scroll.
|
|
for _ in 0..20 {
|
|
app.update(key(KeyCode::PageUp));
|
|
}
|
|
// Cap should be at total_wrapped - visible = 30 - 10 = 20.
|
|
assert_eq!(app.output_scroll, 20);
|
|
}
|
|
|
|
#[test]
|
|
fn note_output_viewport_clamps_a_drifted_scroll_value() {
|
|
// If the scroll value was set high while the viewport
|
|
// was unknown (e.g. before the first render), the next
|
|
// render's report should bring it back into range.
|
|
let mut app = App::new();
|
|
for i in 0..30 {
|
|
app.note_system(format!("line{i}"));
|
|
}
|
|
app.output_scroll = 100;
|
|
app.note_output_viewport(10, 30);
|
|
assert_eq!(app.output_scroll, 20);
|
|
}
|
|
|
|
#[test]
|
|
fn history_recall_places_cursor_at_end() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop table A");
|
|
submit(&mut app);
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table A");
|
|
assert_eq!(app.input_cursor, "drop table A".len());
|
|
}
|
|
|
|
#[test]
|
|
fn history_records_submitted_lines() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop table A");
|
|
submit(&mut app);
|
|
type_str(&mut app, "drop table B");
|
|
submit(&mut app);
|
|
assert_eq!(
|
|
app.history,
|
|
vec!["drop table A".to_string(), "drop table B".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn history_skips_consecutive_duplicates() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop table A");
|
|
submit(&mut app);
|
|
type_str(&mut app, "drop table A");
|
|
submit(&mut app);
|
|
type_str(&mut app, "drop table B");
|
|
submit(&mut app);
|
|
type_str(&mut app, "drop table A");
|
|
submit(&mut app);
|
|
assert_eq!(
|
|
app.history,
|
|
vec![
|
|
"drop table A".to_string(),
|
|
"drop table B".to_string(),
|
|
"drop table A".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn up_arrow_recalls_most_recent_history_entry() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop table A");
|
|
submit(&mut app);
|
|
type_str(&mut app, "drop table B");
|
|
submit(&mut app);
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table B");
|
|
}
|
|
|
|
#[test]
|
|
fn up_arrow_walks_backwards_through_history() {
|
|
let mut app = App::new();
|
|
for line in ["drop table A", "drop table B", "drop table C"] {
|
|
type_str(&mut app, line);
|
|
submit(&mut app);
|
|
}
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table C");
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table B");
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table A");
|
|
// Going past the oldest holds at the oldest.
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table A");
|
|
}
|
|
|
|
#[test]
|
|
fn down_arrow_returns_through_history_to_the_draft() {
|
|
let mut app = App::new();
|
|
for line in ["drop table A", "drop table B"] {
|
|
type_str(&mut app, line);
|
|
submit(&mut app);
|
|
}
|
|
// Type a draft, then start navigating.
|
|
type_str(&mut app, "in progress");
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table B");
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table A");
|
|
app.update(key(KeyCode::Down));
|
|
assert_eq!(app.input, "drop table B");
|
|
app.update(key(KeyCode::Down));
|
|
// Past the newest, restore the draft.
|
|
assert_eq!(app.input, "in progress");
|
|
}
|
|
|
|
#[test]
|
|
fn down_arrow_with_no_history_navigation_is_a_noop() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "draft");
|
|
app.update(key(KeyCode::Down));
|
|
assert_eq!(app.input, "draft");
|
|
}
|
|
|
|
#[test]
|
|
fn editing_during_history_navigation_cancels_it() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop table A");
|
|
submit(&mut app);
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(app.input, "drop table A");
|
|
// Editing the recalled line cancels navigation: another
|
|
// Up press should re-enter navigation from the new draft.
|
|
type_str(&mut app, "X");
|
|
assert_eq!(app.input, "drop table AX");
|
|
app.update(key(KeyCode::Up));
|
|
// Up brings the most recent history back, saving the
|
|
// edited draft.
|
|
assert_eq!(app.input, "drop table A");
|
|
app.update(key(KeyCode::Down));
|
|
assert_eq!(app.input, "drop table AX");
|
|
}
|
|
|
|
#[test]
|
|
fn add_column_with_text_type_emits_execute_action() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "add column to table T: Name (text)");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(
|
|
actions,
|
|
vec![Action::ExecuteDsl(Command::AddColumn {
|
|
table: "T".to_string(),
|
|
column: "Name".to_string(),
|
|
ty: Type::Text,
|
|
})]
|
|
);
|
|
}
|
|
}
|