Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 69 additions & 62 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ categories = ["database", "gui"]

[dependencies]
# One dependency graph: the UI toolkit, the scriptable-shell runtime and the
# component catalog all come from the same pinned revision. Mixing the
# component catalog all come from the same release tag. Mixing the
# crates.io release with the Git one would put two incompatible copies of
# `gpui-base` in the build.
# `tree-sitter-sql`: the SQL editor and the SQL in `.dash` heredocs are the only
# code DuckLocal highlights, so it links that one grammar rather than the set.
gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da", features = ["tree-sitter-sql"] }
gpui-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da" }
gpui-component-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da" }
gpui-kit = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0", features = ["tree-sitter-sql"] }
gpui-shell = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0" }
gpui-component-shell = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0" }
duckdb = { version = "1", features = ["bundled", "json", "parquet"] }
calamine = { version = "0.36", features = ["dates"] }
smol = "2"
Expand All @@ -37,14 +37,15 @@ ureq = { version = "3", default-features = false, features = ["rustls"] }
hmac = "0.12"
sha2 = "0.10"
quick-xml = { version = "0.36", features = ["serialize"] }
unicode-width = "0.2"

[dev-dependencies]
rust_xlsxwriter = "0.99"

# `gpui-shell` scripts run on the QuickJS fork the toolkit pins; the crates.io
# `rquickjs` would be a second, incompatible copy of the same crate.
[patch.crates-io]
rquickjs = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da" }
rquickjs = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0" }

# Fast over small. The opt-level reaches DuckDB's C++ too: libduckdb-sys
# builds it with the `cc` crate, which passes Cargo's level on to the
Expand Down
6 changes: 3 additions & 3 deletions examples/relation_panel/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,9 @@ export default class RelationPanel extends View {
.child(
new Empty().child(
new EmptyHeader()
.child(new EmptyMedia())
.child(new EmptyTitle().child("This connection has no relations"))
.child(
.media(new EmptyMedia())
.title(new EmptyTitle().child("This connection has no relations"))
.description(
new EmptyDescription().child(
"Open a data file or database in the main window first; this app queries that connection.",
),
Expand Down
17 changes: 12 additions & 5 deletions src/app_export/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,13 @@ pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
/// that waits on it can see whether the mount worked.
type MountSlot = Rc<RefCell<Option<Result<gpui_kit::Entity<gpui_shell::ScriptView>, String>>>>;

/// The window's root: the app, when it mounted, and nothing when it did not.
/// The window's content: the app, when it mounted, and nothing when it did not.
///
/// A window needs a root view, and the root has to be one type whether the
/// app loaded or not — so this holds either.
/// `gpui_kit::open_window` wraps this in the `Root` every workspace window has,
/// so a script component that raises a dialog or a notification finds the host
/// it would find on screen instead of panicking on a root it cannot name. The
/// content has to be one type whether the app loaded or not, so this holds
/// either.
struct AppHost(Option<gpui_kit::Entity<gpui_shell::ScriptView>>);

impl Render for AppHost {
Expand Down Expand Up @@ -144,7 +147,11 @@ pub fn capture(job: Job, finish: impl FnOnce(Outcome) -> std::convert::Infallibl
let slot = mounted.clone();
let app = job.app.clone();
let options = hidden_window(cx);
let window = match cx.open_window(options, move |window, cx| {
// The same entry point the main window uses. `runtime::create`
// above already ran the component initializer the helper asks for,
// so the Root it wraps around the host mounts the overlay layers a
// .dash app can ask for, exactly as on screen.
let window = match gpui_kit::open_window(options, cx, move |window, cx| {
// The app's folder answers `appDir()` for this call and for
// every host call `init` makes inside it.
let view = host::with_panel_directory(&app, || {
Expand All @@ -161,7 +168,7 @@ pub fn capture(job: Job, finish: impl FnOnce(Outcome) -> std::convert::Infallibl
}
}
}) {
Ok(window) => window,
Ok((window, _)) => window,
Err(error) => stop!(Outcome::Failed(format!("{error:#}"))),
};
// Opening the window drew one frame, so an app that renders
Expand Down
25 changes: 12 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ mod spec;
mod state;
mod ui;

use gpui_kit::component::{Root, Theme, ThemeMode, TitleBar};
use gpui_kit::component::{Theme, ThemeMode, TitleBar};
use gpui_kit::*;

use crate::app::DuckLocalApp;
Expand Down Expand Up @@ -68,18 +68,17 @@ fn main() {
ui::scale::apply(cx);

let window_bounds = Bounds::centered(None, size(px(1440.), px(900.)), cx);
cx.spawn(async move |cx| {
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(window_bounds)),
window_min_size: Some(size(px(960.), px(600.))),
..TitleBar::window_options()
};
cx.open_window(options, |window, cx| {
let view = cx.new(|cx| DuckLocalApp::new(paths, window, cx));
cx.new(|cx| Root::new(view, window, cx))
})
.expect("Failed to open window");
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(window_bounds)),
window_min_size: Some(size(px(960.), px(600.))),
..TitleBar::window_options()
};
// The helper wraps the view in the Root that hosts overlays and
// window chrome; the run closure already holds `&mut App`, so no
// spawn is needed to reach one.
gpui_kit::open_window(options, cx, move |window, cx| {
cx.new(|cx| DuckLocalApp::new(paths, window, cx))
})
.detach();
.expect("Failed to open window");
});
}
184 changes: 184 additions & 0 deletions src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//!
//! Blocking functions; call via `smol::unblock` from UI code.

use std::ops::Range;
use std::time::Instant;

use anyhow::Result;
Expand Down Expand Up @@ -517,6 +518,96 @@ pub fn run_of(conn: &Connection, sql: &str) -> Result<QueryOutcome> {
}))
}

/// The UTF-8 byte range in `sql` that an error `message` points at, if it
/// points at all.
///
/// Parser and binder errors render their position as a caret under the
/// offending line:
///
/// ```text
/// Catalog Error: Table with name t does not exist!
///
/// LINE 2: from t
/// ^
/// ```
///
/// The caret column counts *characters*, and the caret line is indented by
/// the whole `LINE n: ` label, so the label width comes off before the column
/// means anything. One caret marks where the token starts, so the range
/// extends over the token; a run of carets already spans it. Errors without a
/// position (runtime failures, a batch whose bind error carries none) return
/// `None`, and a rendered line that does not match the SQL — a DuckDB that
/// truncates long lines would produce one — yields `None` rather than a
/// squiggle in the wrong place.
pub fn error_byte_range(sql: &str, message: &str) -> Option<Range<usize>> {
let mut rendered = message.lines();
while let Some(header) = rendered.next() {
let Some(rest) = header.strip_prefix("LINE ") else {
continue;
};
let Some((digits, shown)) = rest.split_once(": ") else {
continue;
};
let Ok(number) = digits.parse::<usize>() else {
continue;
};
let caret = rendered.next().unwrap_or("");
let padding = caret.len() - caret.trim_start_matches(' ').len();
let label = "LINE ".len() + digits.len() + ": ".len();
let column = padding.checked_sub(label)?;
let carets = caret
.trim_start_matches(' ')
.chars()
.take_while(|&c| c == '^')
.count();

// The line the number refers to, in the SQL that was run.
let mut line_start = 0;
for _ in 1..number {
line_start = sql[line_start..].find('\n').map(|ix| line_start + ix + 1)?;
}
let line_end = sql[line_start..]
.find('\n')
.map(|ix| line_start + ix)
.unwrap_or(sql.len());
let line = &sql[line_start..line_end];
// Trust the position only while the rendered line is the SQL's own;
// otherwise the column maps to text the user never wrote.
if shown != line {
continue;
}

let byte_of_char = |index: usize| {
line.char_indices()
.nth(index)
.map(|(ix, _)| ix)
.unwrap_or(line.len())
};
let start = line_start + byte_of_char(column);
let end = if carets > 1 {
line_start + byte_of_char(column + carets)
} else {
// One caret marks where the token starts; underline the token.
let mut end = start;
for ch in sql[start..line_end].chars() {
if ch.is_whitespace() {
break;
}
end += ch.len_utf8();
}
end
};
if start < end {
return Some(start..end);
}
// A caret at or past the line's end marks nothing on its own; fall
// back to the last character, which is where the caret was read.
let (ix, ch) = line.char_indices().next_back()?;
return Some(line_start + ix..line_start + ix + ch.len_utf8());
}
None
}

/// `EXPLAIN <sql>` rendered as plain text lines.
pub fn explain_of(conn: &Connection, sql: &str) -> Result<(Vec<String>, u128)> {
let started = Instant::now();
Expand Down Expand Up @@ -850,6 +941,79 @@ mod tests {
Connection::open_in_memory().unwrap()
}

/// The message shapes here are real DuckDB renderings; the assertions are
/// byte ranges into the SQL the editor holds.
#[test]
fn error_positions_map_to_sql_bytes() {
// A binder error names the column; one caret underlines the token.
assert_eq!(
error_byte_range(
"select frum t",
"Binder Error: Referenced column \"frum\" was not found\n\nLINE 1: select frum t\n ^",
),
Some(7..11)
);

// A later line, and a label wider than one digit.
assert_eq!(
error_byte_range(
"-- 1\n-- 2\n-- 3\n-- 4\n-- 5\n-- 6\n-- 7\n-- 8\n-- 9\nselect nope",
"Binder Error: Referenced column \"nope\" was not found\n\nLINE 10: select nope\n ^",
),
Some(52..56)
);

// The caret counts characters, not bytes: `é` is two bytes, so the
// byte range sits one past the character column.
assert_eq!(
error_byte_range(
"select 'aé' as x, nope",
"Binder Error: Referenced column \"nope\" was not found\n\nLINE 1: select 'aé' as x, nope\n ^",
),
Some(19..23)
);

// A run of carets already spans the token, including its last byte.
assert_eq!(
error_byte_range(
"select 1\nfrom missing",
"Catalog Error: Table with name missing does not exist!\n\nLINE 2: from missing\n ^^^^^^^",
),
Some(14..21)
);

// A caret at the end of the line still marks the last character.
assert_eq!(
error_byte_range(
"select 'aé' +",
"Binder Error: No function matches ...\n\nLINE 1: select 'aé' +\n ^",
),
Some(13..14)
);
}

#[test]
fn errors_without_a_position_mark_nothing() {
assert_eq!(
error_byte_range("select *", "Parser Error: syntax error at end of input"),
None
);
// A rendered line that is not the SQL's own — as a truncation of a
// long line would read — is refused rather than misplaced.
assert_eq!(
error_byte_range(
"select frum t",
"Parser Error: ...\n\nLINE 1: select …\n ^",
),
None
);
// The line number must exist in the SQL that was run.
assert_eq!(
error_byte_range("select 1", "Parser Error: ...\n\nLINE 9: select 1\n ^"),
None
);
}

#[test]
fn select_returns_typed_columns() {
let conn = mem();
Expand Down Expand Up @@ -1248,4 +1412,24 @@ mod tests {
"_No columns were returned._\n"
);
}

/// The crafted messages above pin the parsing; this pins the parsing
/// against the messages the bundled DuckDB actually renders.
#[test]
fn real_duckdb_errors_map_to_the_span_they_render() {
let conn = mem();
let sql = "select 1\nfrom missing";
let error = run_of(&conn, sql).unwrap_err().to_string();
assert_eq!(error_byte_range(sql, &error), Some(14..21), "{error}");

// Multibyte text before the caret: the column counts characters.
let sql = "select 'aé' as x, nope";
let error = run_of(&conn, sql).unwrap_err().to_string();
assert_eq!(error_byte_range(sql, &error), Some(19..23), "{error}");

// A runtime error renders no position, so nothing is marked.
let sql = "select error('boom')";
let error = run_of(&conn, sql).unwrap_err().to_string();
assert_eq!(error_byte_range(sql, &error), None, "{error}");
}
}
2 changes: 2 additions & 0 deletions src/spec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! src/spec/model.rs what the tree means, checked without a database
//! src/spec/mod.rs `ducklocal check`, the command half
//! src/spec/prepare.rs a plot's data, derived from its query's result once
//! src/spec/plot.rs the plots the catalog charts cannot draw honestly
//! src/spec/highlight.rs colours for the source editor, SQL heredocs included
//! src/spec/view.rs the view half: a `.dash` file as a workspace tab
//! src/spec/tabs.rs which specs are open, remembered between launches
Expand All @@ -30,6 +31,7 @@ pub mod complete;
pub mod highlight;
pub mod lsp;
pub mod model;
pub mod plot;
pub mod prepare;
pub mod syntax;
pub mod tabs;
Expand Down
Loading
Loading