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
22 changes: 11 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ categories = ["database", "gui"]
# `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", 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" }
gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0", features = ["tree-sitter-sql"] }
gpui-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0" }
gpui-component-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0" }
duckdb = { version = "1", features = ["bundled", "json", "parquet"] }
calamine = { version = "0.36", features = ["dates"] }
smol = "2"
Expand All @@ -45,7 +45,7 @@ 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", tag = "v0.7.0" }
rquickjs = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0" }

# 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
35 changes: 35 additions & 0 deletions assets/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,40 @@
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.ducklocal.dash</string>
<key>UTTypeDescription</key>
<string>DuckLocal Dashboard Spec</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.text</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>dash</string>
</array>
</dict>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>DuckLocal Dashboard</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.ducklocal.dash</string>
</array>
</dict>
</array>
</dict>
</plist>
4 changes: 2 additions & 2 deletions skills/ducklocal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ plot "revenue" {
}
```

A `query` block holds one `sql` attribute (one read-only statement — SELECT, WITH, FROM, VALUES, SHOW, DESCRIBE, SUMMARIZE or PIVOT — heredoc or string; DDL, DML, COPY, ATTACH and INSTALL are rejected). A `plot` block holds `type` (`line`, `bar`, `area`, `scatter`, `table`), `query` (a `query.name` reference), `x` and `y` (result columns, bare identifiers or quoted strings; `y` optional for `table`), optional `series` and `title`. No functions, conditionals, or interpolation exist. There is a working example at `examples/analysis_app/dashboard.dash`.
A `query` block holds one `sql` attribute (one read-only statement — SELECT, WITH, FROM, VALUES, SHOW, DESCRIBE, SUMMARIZE or PIVOT — heredoc or string; DDL, DML, COPY, ATTACH and INSTALL are rejected). A `plot` block holds `type` (`line`, `bar`, `area`, `scatter`, `pie`, `map`, `table`), `query` (a `query.name` reference), `x` and `y` (result columns, bare identifiers or quoted strings; `y` optional for `table`), optional `series` and `title`. A `pie` takes `x` as its slices and `y` as their sizes, and no `series`; it folds past eight slices into "other". A `map` takes `lat` and `lng` (degrees) instead of `x`/`y`/`series` — either may be omitted when a numeric column's name says it, such as `geo_lat` or `longitude` — plus an optional `color` column; for example `plot "stations" { type = "map" query = query.stations lat = geo_lat lng = geo_lng color = type }`. No functions, conditionals, or interpolation exist. There is a working example at `examples/analysis_app/dashboard.dash`.

Always validate before handing a spec over: `ducklocal check dashboard.dash`, or `ducklocal check dashboard.dash --database warehouse.duckdb` to also run every query read-only and verify every `x`/`y`/`series` against the columns the queries actually return (a non-numeric `y` is an error outside `table`). A spec mistake is exit 2 with kind `spec`, one `file:line: message` per diagnostic; a query that fails on the database is exit 1 with kind `sql`, one line per failing query — fix all of them, not just the first. Prefer the `--database` form whenever the database exists: without it nothing runs, so errors that appear only at execution (a cast the build cannot perform, a value that will not convert) go unseen. To see it rendered, open the file in the GUI (`ducklocal dashboard.dash` or drag it onto the window): it becomes a dashboard tab, a resizable vertical stack of the plots with per-plot inline errors.
Always validate before handing a spec over: `ducklocal check dashboard.dash`, or `ducklocal check dashboard.dash --database warehouse.duckdb` to also run every query read-only and verify every `x`/`y`/`series`/`lat`/`lng`/`color` against the columns the queries actually return (a non-numeric `y` is an error outside `table`, and so is a non-numeric `lat` or `lng`). A spec mistake is exit 2 with kind `spec`, one `file:line: message` per diagnostic; a query that fails on the database is exit 1 with kind `sql`, one line per failing query — fix all of them, not just the first. Prefer the `--database` form whenever the database exists: without it nothing runs, so errors that appear only at execution (a cast the build cannot perform, a value that will not convert) go unseen. To see it rendered, open the file in the GUI (`ducklocal dashboard.dash` or drag it onto the window): it becomes a dashboard tab, a resizable vertical stack of the plots with per-plot inline errors.

## Authoring an app

Expand Down
127 changes: 118 additions & 9 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,54 @@ use gpui_kit::component::resizable::{h_resizable, resizable_panel};
use gpui_kit::component::{v_flex, ActiveTheme, Sizable, WindowExt};
use gpui_kit::prelude::FluentBuilder;
use gpui_kit::*;
use std::sync::{Mutex, PoisonError};
use std::time::Duration;

/// Documents Finder asks the app to open — "Open With", a double-clicked
/// `.dash`, a drop on the Dock icon — while it runs or as it launches. The
/// platform's openURLs callback gets no App context, so it queues the paths
/// here and the root view drains them once a frame is up.
static FINDER_OPENS: Mutex<Vec<String>> = Mutex::new(Vec::new());

/// Queue one URL from the platform's openURLs callback. Only `file://` URLs
/// are documents; anything else was not meant for us.
pub(crate) fn queue_finder_open(url: &str) {
if let Some(path) = file_url_path(url) {
FINDER_OPENS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(path);
}
}

/// The path a `file://` URL points at. Percent-escapes are UTF-8; the
/// authority is empty or `localhost` for a local file.
fn file_url_path(url: &str) -> Option<String> {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}

let authority_and_path = url.strip_prefix("file://")?;
let path_start = authority_and_path.find('/')?;
let encoded = &authority_and_path[path_start..];
let mut decoded = Vec::with_capacity(encoded.len());
let mut bytes = encoded.bytes();
while let Some(byte) = bytes.next() {
if byte == b'%' {
let hi = hex(bytes.next()?)?;
let lo = hex(bytes.next()?)?;
decoded.push(hi << 4 | lo);
} else {
decoded.push(byte);
}
}
String::from_utf8(decoded).ok()
}

use crate::analysis::apps;
use crate::i18n::{tr, trf};
Expand Down Expand Up @@ -134,6 +182,31 @@ impl DuckLocalApp {
})
.detach();

// Finder opens queue from the moment the platform callback is
// registered — possibly before this view exists — so drain on a slow
// poll and open them like a drop on the window. The task ends with
// the window: `update_in` fails once the view is gone.
cx.spawn_in(window, async move |this, cx| {
loop {
smol::Timer::after(Duration::from_millis(200)).await;
let paths: Vec<String> = std::mem::take(
&mut *FINDER_OPENS
.lock()
.unwrap_or_else(PoisonError::into_inner),
);
if paths.is_empty() {
continue;
}
if this
.update_in(cx, |this, window, cx| this.open_external(paths, window, cx))
.is_err()
{
break;
}
}
})
.detach();

Self {
state,
title_bar,
Expand All @@ -143,15 +216,11 @@ impl DuckLocalApp {
}
}

/// Paths dropped on the window: an app directory opens an app tab, a
/// `.dash` file a dashboard tab, and everything else is the same request
/// the command line and the pickers make.
fn drop_paths(&mut self, paths: &ExternalPaths, window: &mut Window, cx: &mut Context<Self>) {
let requested: Vec<String> = paths
.paths()
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect();
/// Paths from outside the app — a drop on the window, or a document
/// Finder opens with it: an app directory opens an app tab, a `.dash`
/// file a dashboard tab, and everything else is the same request the
/// command line and the pickers make.
fn open_external(&mut self, requested: Vec<String>, window: &mut Window, cx: &mut Context<Self>) {
let (directories, rest) = apps::split_paths(&requested);
for directory in directories {
self.workspace
Expand All @@ -166,6 +235,15 @@ impl DuckLocalApp {
open_paths(self.state.clone(), data, window, cx);
}
}

fn drop_paths(&mut self, paths: &ExternalPaths, window: &mut Window, cx: &mut Context<Self>) {
let requested: Vec<String> = paths
.paths()
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect();
self.open_external(requested, window, cx);
}
}

impl Render for DuckLocalApp {
Expand Down Expand Up @@ -231,3 +309,34 @@ impl Render for DuckLocalApp {
.child(self.status_bar.clone())
}
}

#[cfg(test)]
mod tests {
// Deliberately not `use super::*`: that pulls in `gpui_kit::*`, whose
// `test` macro shadows the built-in `#[test]`.
use super::file_url_path;

#[test]
fn file_urls_decode_to_paths() {
assert_eq!(
file_url_path("file:///Users/admin/aiops2_overview.dash"),
Some("/Users/admin/aiops2_overview.dash".to_string())
);
// Spaces and CJK arrive percent-encoded.
assert_eq!(
file_url_path("file:///Users/admin/My%20Reports/%E6%97%A5%E6%8A%A5.dash"),
Some("/Users/admin/My Reports/日报.dash".to_string())
);
assert_eq!(
file_url_path("file://localhost/Users/admin/x.dash"),
Some("/Users/admin/x.dash".to_string())
);
}

#[test]
fn non_file_urls_are_not_documents() {
assert_eq!(file_url_path("https://example.com/x.dash"), None);
assert_eq!(file_url_path("file://"), None);
assert_eq!(file_url_path("file:///bad%zz"), None);
}
}
5 changes: 5 additions & 0 deletions src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,11 @@ static STRINGS: &[(&str, &str, &str)] = &[
"查询结果中没有列 {}。可用的列:{}",
"The query result has no column {}. Available columns: {}",
),
(
"dashboard.map_no_coordinates",
"地图需要经纬度列:请用 lat 和 lng 指定。查询返回的列:{}",
"A map needs coordinate columns: name them with lat and lng. The query returns: {}",
),
(
"dashboard.empty",
"这个 spec 没有声明任何 plot 块。",
Expand Down
13 changes: 10 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,16 @@ fn main() {
.filter(|arg| !arg.starts_with("-psn_"))
.collect();

gpui_kit::application()
.with_assets(assets::AppAssets)
.run(move |cx| {
let application = gpui_kit::application().with_assets(assets::AppAssets);
// Finder's "Open With" arrives here as file:// URLs — on launch and on
// every later open — and the callback gets no App context, so the paths
// are queued for the root view to drain (app.rs).
application.on_open_urls(|urls| {
for url in urls {
crate::app::queue_finder_open(&url);
}
});
application.run(move |cx| {
gpui_kit::init(cx);
ui::init(cx);
Theme::change(ThemeMode::Light, None, cx);
Expand Down
Loading
Loading