sqlight: sqlite playground

This commit is contained in:
Spxg
2025-04-26 22:13:23 +08:00
commit f137bd53b5
97 changed files with 11234 additions and 0 deletions

68
src/app/button_set.rs Normal file
View File

@@ -0,0 +1,68 @@
use istyles::istyles;
use leptos::{prelude::*, tachys::html};
use web_sys::MouseEvent;
istyles!(styles, "assets/module.postcss/button_set.module.css.map");
#[component]
pub fn ButtonSet(
#[prop(default = String::new())] class_name: String,
children: Children,
) -> impl IntoView {
let class = format!("{} {}", styles::set, class_name);
view! { <div class=class>{children()}</div> }
}
#[component]
pub fn Button<C>(
#[prop(default = false)] is_primary: bool,
#[prop(default = false)] is_small: bool,
#[prop(optional)] icon_left: Option<AnyView>,
#[prop(optional)] icon_right: Option<AnyView>,
#[prop(optional)] node_ref: NodeRef<html::element::Button>,
on_click: C,
children: Children,
) -> impl IntoView
where
C: FnMut(MouseEvent) + Send + 'static,
{
let class = format!(
"{} {}",
if is_primary {
styles::primary
} else {
styles::secondary
},
if is_small { styles::small } else { "" }
);
let icon_left = move || {
if let Some(icon) = icon_left {
view! { <span class=styles::iconLeft>{icon}</span> }.into_any()
} else {
().into_any()
}
};
let icon_right = move || {
if let Some(icon) = icon_right {
view! { <span class=styles::iconRight>{icon}</span> }.into_any()
} else {
().into_any()
}
};
view! {
<button type="button" class=class on:click=on_click node_ref=node_ref>
{icon_left()}
{children()}
{icon_right()}
</button>
}
}
#[component]
pub fn Rule() -> impl IntoView {
view! { <span class=styles::rule /> }
}

50
src/app/config_element.rs Normal file
View File

@@ -0,0 +1,50 @@
use istyles::istyles;
use leptos::prelude::*;
use web_sys::Event;
use crate::app::menu_item::MenuItem;
istyles!(
styles,
"assets/module.postcss/config_element.module.css.map"
);
#[component]
pub fn Select<E>(
on_change: E,
name: String,
#[prop(default = true)] is_default: bool,
children: Children,
) -> impl IntoView
where
E: FnMut(Event) + Send + 'static,
{
view! {
<ConfigElement name=name is_default=is_default>
<select class=styles::select on:change=on_change>
{children()}
</select>
</ConfigElement>
}
}
#[component]
pub fn ConfigElement(
name: String,
#[prop(default = true)] is_default: bool,
children: Children,
) -> impl IntoView {
let style = if is_default {
styles::name
} else {
styles::notDefault
};
view! {
<MenuItem>
<div class=styles::container>
<span class=style>{name}</span>
<div class=styles::value>{children()}</div>
</div>
</MenuItem>
}
}

118
src/app/config_menu.rs Normal file
View File

@@ -0,0 +1,118 @@
use leptos::prelude::*;
use reactive_stores::Store;
use wasm_bindgen::JsValue;
use web_sys::{Event, HtmlSelectElement};
use crate::{
SQLightError,
app::{
GlobalState, GlobalStateStoreFields, Orientation, Theme,
config_element::Select as SelectConfig, menu_group::MenuGroup,
},
};
const ACE_KEYBOARDS: [&str; 5] = ["ace", "emacs", "sublime", "vim", "vscode"];
const ACE_THEMES: [&str; 3] = ["github", "github_dark", "gruvbox"];
fn selecet_view(s: &str, selected: &str) -> AnyView {
if s == selected {
view! {
<option selected value=s>
{s}
</option>
}
.into_any()
} else {
view! { <option value=s>{s}</option> }.into_any()
}
}
#[component]
pub fn ConfigMenu() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let ace_keyboard_change = move |event: Event| {
if let Some(target) = event.target() {
let select = HtmlSelectElement::from(JsValue::from(target));
state.editor_config().write().keyboard = select.value();
if let Some(Err(err)) = state.editor().read().as_ref().map(|editor| {
editor.set_keyboard_handler(&format!("ace/keyboard/{}", select.value()))
}) {
state
.last_error()
.set(Some(SQLightError::new_ace_editor(err)));
}
}
};
let ace_theme_change = move |event: Event| {
if let Some(target) = event.target() {
let select = HtmlSelectElement::from(JsValue::from(target));
state.editor_config().write().theme = select.value();
if let Some(Err(err)) = state
.editor()
.read()
.as_ref()
.map(|editor| editor.set_theme(&format!("ace/theme/{}", select.value())))
{
state
.last_error()
.set(Some(SQLightError::new_ace_editor(err)));
}
}
};
let theme_change = move |event: Event| {
if let Some(target) = event.target() {
let select = HtmlSelectElement::from(JsValue::from(target));
*state.theme().write() = Theme::from_value(&select.value());
}
};
let orientation_change = move |event: Event| {
if let Some(target) = event.target() {
let select = HtmlSelectElement::from(JsValue::from(target));
*state.orientation().write() = Orientation::from_value(&select.value());
}
};
view! {
<MenuGroup title="Editor".into()>
<SelectConfig name="Keybinding".into() on_change=ace_keyboard_change>
{move || {
ACE_KEYBOARDS
.into_iter()
.map(|s| selecet_view(s, &state.editor_config().read().keyboard))
.collect_view()
}}
</SelectConfig>
<SelectConfig name="Theme".into() on_change=ace_theme_change>
{move || {
ACE_THEMES
.into_iter()
.map(|s| selecet_view(s, &state.editor_config().read().theme))
.collect_view()
}}
</SelectConfig>
</MenuGroup>
<MenuGroup title="UI".into()>
<SelectConfig name="Theme".into() on_change=theme_change>
{move || {
["System", "Light", "Dark"]
.into_iter()
.map(|s| selecet_view(s, &state.theme().read().to_value()))
.collect_view()
}}
</SelectConfig>
<SelectConfig name="Orientation".into() on_change=orientation_change>
{move || {
["Automatic", "Horizontal", "Vertical"]
.into_iter()
.map(|s| selecet_view(s, &state.orientation().read().to_value()))
.collect_view()
}}
</SelectConfig>
</MenuGroup>
}
}

36
src/app/context_menu.rs Normal file
View File

@@ -0,0 +1,36 @@
use leptos::prelude::*;
use reactive_stores::Store;
use crate::app::{
GlobalState, GlobalStateStoreFields, menu_group::MenuGroup, select_one::SelectOne,
};
#[component]
pub fn ContextMenu() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
view! {
<MenuGroup title="Choose whether to keep the context".into()>
<SelectOne
name="Discard Context".into()
current_value=move || { *state.keep_ctx().read() }
this_value=false
change_value=move || {
*state.keep_ctx().write() = false;
}
>
"Each execution is in a new DB."
</SelectOne>
<SelectOne
name="Keep Context".into()
current_value=move || { *state.keep_ctx().read() }
this_value=true
change_value=move || {
*state.keep_ctx().write() = true;
}
>
"Keep the results of each execution."
</SelectOne>
</MenuGroup>
}
}

60
src/app/editor.rs Normal file
View File

@@ -0,0 +1,60 @@
use aceditor::EditorOptionsBuilder;
use istyles::istyles;
use leptos::prelude::*;
use reactive_stores::Store;
use wasm_bindgen_futures::spawn_local;
use web_sys::UrlSearchParams;
use crate::{
SQLightError,
app::{GlobalState, GlobalStateStoreFields, header::execute},
};
istyles!(styles, "assets/module.postcss/editor.module.css.map");
#[component]
pub fn Editor() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let editor_ref = NodeRef::new();
editor_ref.on_load(move |_| {
let shared_code = || {
let search = window().location().search().ok()?;
let params = UrlSearchParams::new_with_str(&search).ok()?;
params.get("code")
};
let opt = EditorOptionsBuilder::default()
.mode("ace/mode/sql")
.theme(&format!(
"ace/theme/{}",
state.editor_config().read_untracked().theme
))
.keyboard(&format!(
"ace/keyboard/{}",
state.editor_config().read_untracked().keyboard
))
.value(&shared_code().unwrap_or_else(|| state.code().get_untracked()))
.build();
match aceditor::Editor::open("ace_editor", Some(&opt)) {
Ok(editor) => state.editor().set(Some(editor)),
Err(err) => state
.last_error()
.set(Some(SQLightError::new_ace_editor(err))),
}
spawn_local(async move {
if let Err(err) = aceditor::Editor::define_vim_w(execute(state)).await {
state
.last_error()
.set(Some(SQLightError::new_ace_editor(err)));
}
});
});
view! {
<div class=styles::container>
<div node_ref=editor_ref id="ace_editor" class=styles::ace></div>
</div>
}
}

187
src/app/header.rs Normal file
View File

@@ -0,0 +1,187 @@
use istyles::istyles;
use leptos::{prelude::*, tachys::html};
use reactive_stores::Store;
use web_sys::{Url, UrlSearchParams};
use crate::{
PrepareOptions, WorkerRequest,
app::{
button_set::{Button, ButtonSet, Rule},
config_menu::ConfigMenu,
context_menu::ContextMenu,
icon::{build_icon, config_icon, expandable_icon},
output::change_focus,
pop_button::PopButton,
state::{Focus, GlobalState, GlobalStateStoreFields},
vfs_menu::VfsMenu,
},
};
istyles!(styles, "assets/module.postcss/header.module.css.map");
#[component]
pub fn Header() -> impl IntoView {
let menu_container = NodeRef::new();
view! {
<>
<div id="header" class=styles::container>
<div class=styles::left>
<ButtonSet>
<ExecuteButton />
</ButtonSet>
<ButtonSet>
<VfsMenuButton menu_container=menu_container />
<Rule />
<ContextMenuButton menu_container=menu_container />
</ButtonSet>
</div>
<div class=styles::right>
<ButtonSet>
<ShareButton />
</ButtonSet>
<ButtonSet>
<ConfigMenuButton menu_container=menu_container />
</ButtonSet>
</div>
</div>
<div node_ref=menu_container></div>
</>
}
}
pub fn execute(state: Store<GlobalState>) -> Box<dyn Fn() + Send + 'static> {
Box::new(move || {
let Some(code) = state
.editor()
.read_untracked()
.as_ref()
.map(|editor| editor.get_value())
else {
return;
};
state.code().set(code.clone());
change_focus(state, Some(Focus::Execute));
std::mem::take(&mut *state.output().write());
if let Some(worker) = &*state.worker().read_untracked() {
worker.send_task(WorkerRequest::Prepare(PrepareOptions {
id: String::new(),
sql: code,
clear_on_prepare: !*state.keep_ctx().read_untracked(),
}));
worker.send_task(WorkerRequest::Continue(String::new()));
}
})
}
#[component]
fn ExecuteButton() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let on_click = execute(state);
view! {
<Button is_primary=true icon_right=build_icon() on_click=move |_| on_click()>
"Run"
</Button>
}
}
#[component]
fn VfsMenuButton(menu_container: NodeRef<html::element::Div>) -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let button = move |toggle, node_ref| {
view! {
<Button icon_right=expandable_icon() on_click=toggle node_ref=node_ref>
{move || state.vfs().read().value()}
</Button>
}
.into_any()
};
view! {
<PopButton
button=button
menu=Box::new(|_close| { view! { <VfsMenu /> }.into_any() })
menu_container=menu_container
></PopButton>
}
}
#[component]
fn ContextMenuButton(menu_container: NodeRef<html::element::Div>) -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let button = move |toggle, node_ref| {
view! {
<Button icon_right=expandable_icon() on_click=toggle node_ref=node_ref>
{move || if *state.keep_ctx().read() { "Keep Context" } else { "Discard Context" }}
</Button>
}
.into_any()
};
view! {
<PopButton
button=button
menu=Box::new(|_close| { view! { <ContextMenu /> }.into_any() })
menu_container=menu_container
></PopButton>
}
}
#[component]
fn ConfigMenuButton(menu_container: NodeRef<html::element::Div>) -> impl IntoView {
let button = |toggle, node_ref| {
view! {
<Button
icon_left=config_icon()
icon_right=expandable_icon()
on_click=toggle
node_ref=node_ref
>
"Config"
</Button>
}
.into_any()
};
view! {
<PopButton
button=button
menu=Box::new(|_close| { view! { <ConfigMenu /> }.into_any() })
menu_container=menu_container
></PopButton>
}
}
#[component]
fn ShareButton() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let click = move |_| {
let Some(code) = state
.editor()
.read()
.as_ref()
.map(|editor| editor.get_value())
else {
return;
};
if let Ok(href) = window().location().href().and_then(|href| {
let url = Url::new(&href)?;
let params = UrlSearchParams::new()?;
params.set("code", &code);
url.set_search(&params.to_string().as_string().unwrap());
Ok(url.href())
}) {
*state.share_href().write() = Some(href);
change_focus(state, Some(Focus::Share));
}
};
view! { <Button on_click=click>"Share"</Button> }
}

81
src/app/icon.rs Normal file
View File

@@ -0,0 +1,81 @@
use istyles::istyles;
use leptos::prelude::*;
istyles!(styles, "assets/module.postcss/icon.module.css.map");
pub fn build_icon() -> AnyView {
view! {
<svg
class=styles::icon
height="14"
viewBox="8 4 10 16"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 5v14l11-7z" />
</svg>
}
.into_any()
}
pub fn config_icon() -> AnyView {
view! {
<svg
class=styles::icon
height="15"
viewBox="0 0 24 24"
width="15"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z" />
</svg>
}.into_any()
}
pub fn expandable_icon() -> AnyView {
view! {
<svg
class=styles::icon
height="10"
viewBox="6 8 12 8"
width="10"
opacity="0.5"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />
</svg>
}
.into_any()
}
pub fn checkmark_icon() -> AnyView {
view! {
<svg
class=styles::icon
height="18"
viewBox="2 2 22 22"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
}
.into_any()
}
pub fn clipboard_icon() -> AnyView {
view! {
<svg
class=styles::icon
height="18"
width="18"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="7" y="15" width="7" height="2" />
<rect x="7" y="11" width="10" height="2" />
<rect x="7" y="7" width="10" height="2" />
<path d="M19,3L19,3h-4.18C14.4,1.84,13.3,1,12,1c-1.3,0-2.4,0.84-2.82,2H5h0C4.86,3,4.73,3.01,4.6,3.04 C4.21,3.12,3.86,3.32,3.59,3.59c-0.18,0.18-0.33,0.4-0.43,0.64C3.06,4.46,3,4.72,3,5v14c0,0.27,0.06,0.54,0.16,0.78 c0.1,0.24,0.25,0.45,0.43,0.64c0.27,0.27,0.62,0.47,1.01,0.55C4.73,20.99,4.86,21,5,21h0h14h0c1.1,0,2-0.9,2-2V5 C21,3.9,20.1,3,19,3z M12,2.75c0.41,0,0.75,0.34,0.75,0.75c0,0.41-0.34,0.75-0.75,0.75c-0.41,0-0.75-0.34-0.75-0.75 C11.25,3.09,11.59,2.75,12,2.75z M19,19H5V5h14V19z" />
</svg>
}.into_any()
}

15
src/app/loader.rs Normal file
View File

@@ -0,0 +1,15 @@
use istyles::istyles;
use leptos::prelude::*;
istyles!(styles, "assets/module.postcss/loader.module.css.map");
#[component]
pub fn Loader() -> impl IntoView {
view! {
<div>
<span class=styles::dot>""</span>
<span class=styles::dot>""</span>
<span class=styles::dot>""</span>
</div>
}
}

14
src/app/menu_group.rs Normal file
View File

@@ -0,0 +1,14 @@
use istyles::istyles;
use leptos::prelude::*;
istyles!(styles, "assets/module.postcss/menu_group.module.css.map");
#[component]
pub fn MenuGroup(title: String, children: Children) -> impl IntoView {
view! {
<div class=styles::container>
<h1 class=styles::title>{title}</h1>
<div class=styles::content>{children()}</div>
</div>
}
}

9
src/app/menu_item.rs Normal file
View File

@@ -0,0 +1,9 @@
use istyles::istyles;
use leptos::prelude::*;
istyles!(styles, "assets/module.postcss/menu_item.module.css.map");
#[component]
pub fn MenuItem(children: Children) -> impl IntoView {
view! { <div class=styles::container>{children()}</div> }
}

20
src/app/mod.rs Normal file
View File

@@ -0,0 +1,20 @@
mod button_set;
mod config_element;
mod config_menu;
mod context_menu;
mod editor;
mod header;
mod icon;
mod loader;
mod menu_group;
mod menu_item;
mod output;
mod playground;
mod pop_button;
mod select_one;
mod selectable_menu_item;
mod state;
mod vfs_menu;
pub use playground::playground;
pub use state::*;

141
src/app/output.rs Normal file
View File

@@ -0,0 +1,141 @@
mod execute;
mod header;
mod loader;
mod section;
mod share;
mod simple_pane;
mod status;
use execute::Execute;
use istyles::istyles;
use leptos::prelude::*;
use reactive_stores::Store;
use share::Share;
use status::Status;
use crate::app::state::{Focus, GlobalState, GlobalStateStoreFields};
istyles!(styles, "assets/module.postcss/output.module.css.map");
fn close() -> AnyView {
let state = expect_context::<Store<GlobalState>>();
if state.read().is_focus() {
view! {
<button class=styles::tabClose on:click=move |_| change_focus(state, None)>
"Close"
</button>
}
.into_any()
} else {
().into_any()
}
}
fn body() -> AnyView {
let state = expect_context::<Store<GlobalState>>();
if state.read().is_focus() {
view! {
<>
<div class=styles::body>
<Show
when=move || matches!(*state.focus().read(), Some(Focus::Execute))
fallback=|| ()
>
<Execute />
</Show>
<Show
when=move || matches!(*state.focus().read(), Some(Focus::Share))
fallback=|| ()
>
<Share />
</Show>
<Show
when=move || matches!(*state.focus().read(), Some(Focus::Status))
fallback=|| ()
>
<Status />
</Show>
</div>
</>
}
.into_any()
} else {
().into_any()
}
}
#[component]
pub fn Tab(kind: Focus, label: String) -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let class = move || {
if matches!(*state.focus().read(), Some(focus) if focus == kind) {
styles::tabSelected
} else {
styles::tab
}
};
view! {
<Show when=move || state.opened_focus().read().contains(&kind) fallback=|| ()>
<button class=class>{label.clone()}</button>
</Show>
}
}
#[component]
pub fn Output() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
view! {
<Show when=move || *state.show_something().read() fallback=|| ()>
<div class=styles::container>
<div class=styles::tabs>
<Tab
kind=Focus::Execute
label="Execution".into()
on:click=move |_| change_focus(state, Some(Focus::Execute))
/>
<Tab
kind=Focus::Share
label="Share".into()
on:click=move |_| change_focus(state, Some(Focus::Share))
/>
<Tab
kind=Focus::Status
label="Status".into()
on:click=move |_| change_focus(state, Some(Focus::Status))
/>
{close}
</div>
{body}
</div>
</Show>
}
}
pub fn change_focus(state: Store<GlobalState>, focus: Option<Focus>) {
if let Some(focus) = focus {
state.opened_focus().write().insert(focus);
state
.is_focused()
.maybe_update(|before| !std::mem::replace(before, true));
} else {
state
.is_focused()
.maybe_update(|before| std::mem::replace(before, false));
}
state
.focus()
.maybe_update(|before| std::mem::replace(before, focus) != focus);
state
.show_something()
.maybe_update(|before| !std::mem::replace(before, true));
}

110
src/app/output/execute.rs Normal file
View File

@@ -0,0 +1,110 @@
use istyles::istyles;
use leptos::prelude::*;
use reactive_stores::{Store, StoreFieldIterator};
use crate::app::{
output::{header::Header, loader::Loader, section::Section, simple_pane::SimplePane},
state::{GlobalState, GlobalStateStoreFields},
};
use crate::{SQLiteStatementResult, SQLiteStatementTable};
istyles!(
styles,
"assets/module.postcss/output/execute.module.css.map"
);
fn get_output(table: &SQLiteStatementTable) -> Option<AnyView> {
let Some(values) = &table.values else {
return None;
};
Some(
view! {
<table class=styles::table>
<tr>
{values
.columns
.iter()
.map(|s| {
view! { <th class=styles::tdAndTh>{s.to_string()}</th> }
})
.collect_view()}
</tr>
{values
.rows
.iter()
.map(|row| {
view! {
<tr>
{row
.iter()
.map(|s| {
view! { <td class=styles::tdAndTh>{s.to_string()}</td> }
})
.collect_view()}
</tr>
}
})
.collect_view()}
</table>
}
.into_any(),
)
}
#[component]
fn Output() -> AnyView {
let state = expect_context::<Store<GlobalState>>();
view! {
<>
<Show
when=move || {
state
.output()
.read()
.last()
.is_none_or(|r| !matches!(r, SQLiteStatementResult::Finish))
}
fallback=|| ()
>
<Loader />
</Show>
<For
each=move || state.output().iter_unkeyed().enumerate()
key=|(idx, _)| *idx
children=move |(idx, item)| {
match &*item.read() {
SQLiteStatementResult::Finish => {
view! { <Header label="Finished".into() /> }.into_any()
}
SQLiteStatementResult::Step(table) => {
let label = format!("Statement #{}", idx + 1);
if let Some(output) = get_output(table) {
view! {
<Section label=label>
<p>{output}</p>
</Section>
}
.into_any()
} else {
().into_any()
}
}
}
}
/>
</>
}
.into_any()
}
#[component]
pub fn Execute() -> impl IntoView {
view! {
<SimplePane>
<Output />
</SimplePane>
}
}

9
src/app/output/header.rs Normal file
View File

@@ -0,0 +1,9 @@
use istyles::istyles;
use leptos::prelude::*;
istyles!(styles, "assets/module.postcss/output/header.module.css.map");
#[component]
pub fn Header(label: String) -> impl IntoView {
view! { <span class=styles::container>{label}</span> }
}

13
src/app/output/loader.rs Normal file
View File

@@ -0,0 +1,13 @@
use leptos::prelude::*;
use crate::app::{loader::Loader as GenericLoader, output::header::Header};
#[component]
pub fn Loader() -> impl IntoView {
view! {
<div>
<Header label="Progress".into() />
<GenericLoader />
</div>
}
}

21
src/app/output/section.rs Normal file
View File

@@ -0,0 +1,21 @@
use istyles::istyles;
use leptos::prelude::*;
use crate::app::output::header::Header;
istyles!(
styles,
"assets/module.postcss/output/section.module.css.map"
);
#[component]
pub fn Section(label: String, children: Children) -> impl IntoView {
view! {
<div>
<Header label=label />
<pre>
<code class=styles::code>{children()}</code>
</pre>
</div>
}
}

55
src/app/output/share.rs Normal file
View File

@@ -0,0 +1,55 @@
use std::{sync::Arc, time::Duration};
use istyles::istyles;
use leptos::prelude::*;
use reactive_stores::Store;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use crate::app::{GlobalState, GlobalStateStoreFields, icon::clipboard_icon};
istyles!(styles, "assets/module.postcss/output/share.module.css.map");
#[component]
fn Copied<H>(href: H, children: Children) -> impl IntoView
where
H: Fn() -> String + Send + Sync + 'static,
{
let (copied, set_copied) = signal(false);
let href = Arc::new(href);
let href1 = Arc::clone(&href);
let copy = move |_| {
let href = Arc::clone(&href1);
spawn_local(async move {
set_copied.set(true);
if let Err(err) =
JsFuture::from(window().navigator().clipboard().write_text(&href())).await
{
log::error!("Failed to write href to clipboard: {err:?}");
}
set_timeout(move || set_copied.set(false), Duration::from_millis(1000));
});
};
view! {
<p class=move || { if *copied.read() { styles::active } else { styles::container } }>
<a href=move || href()>{children()}</a>
<button class=styles::button on:click=copy>
{clipboard_icon()}
</button>
<span class=styles::text>"Copied!"</span>
</p>
}
}
#[component]
fn Links() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let code_url = move || state.share_href().get_untracked().unwrap_or_default();
view! { <Copied href=code_url>Embedded code in link</Copied> }
}
#[component]
pub fn Share() -> impl IntoView {
view! { <Links /> }
}

View File

@@ -0,0 +1,6 @@
use leptos::prelude::*;
#[component]
pub fn SimplePane(children: Children) -> impl IntoView {
view! { <div>{children()}</div> }
}

80
src/app/output/status.rs Normal file
View File

@@ -0,0 +1,80 @@
use std::ops::Deref;
use leptos::prelude::*;
use reactive_stores::Store;
use crate::{
SQLightError, SQLitendError, WorkerError,
app::{
GlobalState, GlobalStateStoreFields,
output::{section::Section, simple_pane::SimplePane},
},
};
const OPFS_SAH_POOL_OPENED_DETAILS: &str = "Due to OPFS SyncAccessHandle restrictions, \
the db can only have one web tab access.
Please close other tabs and refresh, or switch to Memory VFS.";
#[component]
pub fn Status() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let show = move || match &*state.last_error().read() {
Some(error) => {
let summary = format!("{}", error.deref());
let details = match error.deref() {
SQLightError::Worker(worker) => match worker {
WorkerError::SQLite(sqlitend_error) => match sqlitend_error {
SQLitendError::ToCStr
| SQLitendError::GetColumnName(_)
| SQLitendError::Utf8Text => {
"This shouldn't happen, please create an issue on github."
}
SQLitendError::OpenDb(_) => {
"If database disk image is malformed, please enable the discard context option and use it once."
}
SQLitendError::Prepare(_) => "Please check if the syntax is correct.",
SQLitendError::Step(_) => {
"If database disk image is malformed, please enable the discard context option and use it once."
}
SQLitendError::UnsupportColumnType(_) => {
"An unsupported type was encountered, please create an issue on github."
}
},
WorkerError::NotFound | WorkerError::OpfsSAHError => {
"This shouldn't happen, please create an issue on github."
}
WorkerError::InvaildState => {
"SQLite is in an abnormal state when executing SQLite."
}
WorkerError::OpfsSAHPoolOpened => OPFS_SAH_POOL_OPENED_DETAILS,
},
SQLightError::AceEditor(ace_editor) => match ace_editor {
aceditor::EditorError::Serde(_)
| aceditor::EditorError::SetTheme(_)
| aceditor::EditorError::SetKeyboardHandler(_)
| aceditor::EditorError::Open(_)
| aceditor::EditorError::DefineEx(_) => {
"This shouldn't happen, please create an issue on github."
}
},
};
view! {
<details open>
<summary>{summary}</summary>
<p>{details}</p>
</details>
}
.into_any()
}
None => view! { "No Error" }.into_any(),
};
view! {
<SimplePane>
<Section label="Last Error".into()>{show}</Section>
</SimplePane>
}
}

250
src/app/playground.rs Normal file
View File

@@ -0,0 +1,250 @@
use std::ops::Deref;
use istyles::istyles;
use leptos::prelude::*;
use leptos::tachys::html;
use reactive_stores::Store;
use split_grid::{Gutter, SplitOptions};
use tokio::sync::mpsc::UnboundedReceiver;
use wasm_bindgen::{JsCast, prelude::Closure};
use wasm_bindgen_futures::spawn_local;
use web_sys::wasm_bindgen::JsValue;
use crate::app::{
Focus,
editor::Editor,
header::Header,
output::{Output, change_focus},
state::{GlobalState, GlobalStateStoreFields, Orientation, Theme, Vfs},
};
use crate::{WorkerHandle, WorkerResponse, handle_state};
istyles!(styles, "assets/module.postcss/playground.module.css.map");
pub fn playground(
worker: (WorkerHandle, UnboundedReceiver<WorkerResponse>),
) -> Box<dyn FnOnce() -> AnyView + 'static> {
Box::new(move || {
let (worker_handle, rx) = worker;
let state = GlobalState::load().unwrap_or_default();
provide_context(Store::new(state));
let state = expect_context::<Store<GlobalState>>();
state.worker().set(Some(worker_handle));
handle_last_error(state);
handle_system_theme(state);
handle_automic_orientation(state);
handle_connect_db(state);
hanlde_save_state(state);
spawn_local(handle_state(state, rx));
view! {
<div id="playground" class=styles::container>
<Header />
<ResizableArea />
</div>
}
.into_any()
})
}
fn hanlde_save_state(state: Store<GlobalState>) {
Effect::new(move || {
state.vfs().track();
state.editor_config().track();
state.orientation().track();
state.theme().track();
state.keep_ctx().track();
state.code().track();
state.read_untracked().save();
});
}
fn handle_connect_db(state: Store<GlobalState>) {
Effect::new(move || {
if let Some(worker) = &*state.worker().read() {
worker.send_task(crate::WorkerRequest::Open(crate::OpenOptions {
filename: "test.db".into(),
persist: *state.vfs().read() == Vfs::OPFS,
}));
}
});
}
fn handle_system_theme(state: Store<GlobalState>) {
Effect::new(move || {
let theme = match *state.theme().read() {
Theme::System => {
if let Ok(Some(query)) = window().match_media("(prefers-color-scheme: dark)") {
if query.matches() { "dark" } else { "light" }
} else {
"light"
}
}
Theme::SystemLight | Theme::Light => "light",
Theme::SystemDark | Theme::Dark => "dark",
};
if let Some(element) = document().document_element() {
element.set_attribute("data-theme", theme).unwrap()
}
});
if let Ok(Some(query)) = window().match_media("(prefers-color-scheme: dark)") {
let f = move |query: web_sys::MediaQueryList| {
if state.theme().get_untracked().is_system() {
*state.theme().write() = if query.matches() {
Theme::SystemDark
} else {
Theme::SystemLight
};
}
};
f(query.clone());
let callback = Closure::<dyn Fn(web_sys::MediaQueryList)>::new(f);
query
.add_event_listener_with_callback("change", callback.as_ref().unchecked_ref())
.unwrap();
callback.forget();
}
}
fn handle_last_error(state: Store<GlobalState>) {
Effect::new(move || {
if state.last_error().read().is_some() {
change_focus(state, Some(Focus::Status));
}
});
}
fn handle_automic_orientation(state: Store<GlobalState>) {
if let Ok(Some(query)) = window().match_media("(max-width: 1600px)") {
let f = move |query: web_sys::MediaQueryList| {
if state.orientation().get_untracked().is_auto() {
*state.orientation().write() = if query.matches() {
Orientation::AutoHorizontal
} else {
Orientation::AutoVertical
};
}
};
f(query.clone());
let callback = Closure::<dyn Fn(web_sys::MediaQueryList)>::new(f);
query
.add_event_listener_with_callback("change", callback.as_ref().unchecked_ref())
.unwrap();
callback.forget();
}
}
fn gird_style() -> String {
let state = expect_context::<Store<GlobalState>>();
let (focused_grid_style, unfocused_grid_style) = match *state.orientation().read() {
Orientation::Horizontal | Orientation::AutoHorizontal => (
styles::resizeableAreaRowOutputFocused.to_string(),
styles::resizeableAreaRowOutputUnfocused.to_string(),
),
Orientation::Automatic | Orientation::Vertical | Orientation::AutoVertical => (
styles::resizeableAreaColumnOutputFocused.to_string(),
styles::resizeableAreaColumnOutputUnfocused.to_string(),
),
};
if state.read().is_focus() {
focused_grid_style
} else {
unfocused_grid_style
}
}
fn handle_outer_style() -> String {
let state = expect_context::<Store<GlobalState>>();
match *state.orientation().read() {
Orientation::Horizontal | Orientation::AutoHorizontal => {
styles::splitRowsGutter.to_string()
}
Orientation::Automatic | Orientation::Vertical | Orientation::AutoVertical => {
styles::splitColumnsGutter.to_string()
}
}
}
fn handle_inner_style() -> String {
let state = expect_context::<Store<GlobalState>>();
match *state.orientation().read() {
Orientation::Horizontal | Orientation::AutoHorizontal => {
styles::splitRowsGutterHandle.to_string()
}
Orientation::Automatic | Orientation::Vertical | Orientation::AutoVertical => String::new(),
}
}
#[component]
fn ResizableArea() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
let node_ref = NodeRef::<html::element::Div>::new();
let drag_handle = NodeRef::<html::element::Div>::new();
Effect::new(move || {
state.orientation().track();
state.is_focused().track();
if let Some(div) = &*node_ref.read() {
let style = div.deref().style();
let _ = style.remove_property("grid-template-columns");
let _ = style.remove_property("grid-template-rows");
}
});
Effect::new(move || {
state.show_something().track();
let element = if let Some(element) = &*drag_handle.read() {
JsValue::from(element)
} else {
JsValue::null()
};
let options = match *state.orientation().read() {
Orientation::Horizontal | Orientation::AutoHorizontal => SplitOptions {
min_size: 100,
row_gutters: Some(vec![Gutter { track: 1, element }]),
column_gutters: None,
},
Orientation::Automatic | Orientation::Vertical | Orientation::AutoVertical => {
SplitOptions {
min_size: 100,
row_gutters: None,
column_gutters: Some(vec![Gutter { track: 1, element }]),
}
}
};
let grid = split_grid::split(&options.into());
on_cleanup(move || grid.destroy());
});
view! {
<div node_ref=node_ref class=gird_style>
<div class=styles::editor>
<Editor />
</div>
<Show when=move || state.read().is_focus() fallback=|| ()>
<div node_ref=drag_handle class=handle_outer_style>
<span class=handle_inner_style>""</span>
</div>
</Show>
<Show when=move || *state.show_something().read() fallback=|| ()>
<div class=styles::output>
<Output />
</div>
</Show>
</div>
}
}

223
src/app/pop_button.rs Normal file
View File

@@ -0,0 +1,223 @@
use std::{ops::Deref, sync::Arc};
use floating_ui::{
ArrowPosition, ComputePosition, MiddlewareData, auto_update, compute_options, compute_position,
};
use istyles::istyles;
use js_sys::Object;
use leptos::{portal::Portal, prelude::*, tachys::html};
use wasm_bindgen::{JsCast, JsValue, prelude::Closure};
use wasm_bindgen_futures::spawn_local;
use web_sys::{KeyboardEvent, MouseEvent};
use crate::FragileComfirmed;
istyles!(styles, "assets/module.postcss/pop_button.module.css.map");
#[component]
pub fn PopButton<B, M>(
button: B,
menu: M,
#[prop(optional)] menu_container: NodeRef<html::element::Div>,
) -> impl IntoView
where
B: FnOnce(Box<dyn FnMut(MouseEvent) + Send>, NodeRef<html::element::Button>) -> AnyView,
M: Fn(Box<dyn Fn()>) -> AnyView + Send + Sync + 'static,
{
let (is_open, set_open) = signal(false);
let toggle = move || set_open.set(!is_open.get());
let close = move || set_open.set(false);
let arrow_ref = NodeRef::<html::element::Div>::new();
let reference_ref = NodeRef::<html::element::Button>::new();
let floating_ref = NodeRef::<html::element::Div>::new();
let menu_ref = NodeRef::<html::element::Div>::new();
Effect::new(move || {
let key_listener = move |event: KeyboardEvent| {
if !is_open.get_untracked() {
return;
}
if event.key() == "Escape" {
set_open(false);
}
};
let callback = FragileComfirmed::new(Closure::<dyn Fn(KeyboardEvent)>::new(key_listener));
window()
.add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref())
.unwrap();
on_cleanup(move || {
window()
.remove_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref())
.unwrap();
drop(callback)
});
});
Effect::new(move || {
let listener = move |event: MouseEvent| {
if !is_open.get_untracked() {
return;
}
if let Some(target) = event.target() {
let node = target.dyn_into::<web_sys::Node>().ok();
if !reference_ref.with_untracked(|reference| {
reference
.as_ref()
.is_some_and(|reference| reference.deref().contains(node.as_ref()))
}) && !floating_ref.with_untracked(|floating| {
floating
.as_ref()
.is_some_and(|floating| floating.deref().contains(node.as_ref()))
}) {
set_open(false);
}
}
};
let callback = FragileComfirmed::new(Closure::<dyn Fn(MouseEvent)>::new(listener));
window()
.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
.unwrap();
on_cleanup(move || {
window()
.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
.unwrap();
drop(callback)
});
});
Effect::new(move || {
let callback = Closure::new(move || {
let options = compute_options(10, &arrow_ref.get_untracked().into());
spawn_local(async move {
let value = compute_position(
reference_ref.get_untracked().into(),
floating_ref.get_untracked().into(),
options,
)
.await;
let ComputePosition {
x,
y,
placement,
strategy,
middleware_data,
} = serde_wasm_bindgen::from_value(value).unwrap();
if let Some(element) = floating_ref.get_untracked() {
let style = element.deref().style();
#[derive(serde::Serialize)]
struct Style {
position: String,
left: String,
top: String,
}
let pos = serde_wasm_bindgen::to_value(&Style {
position: strategy,
left: format!("{x}px"),
top: format!("{y}px"),
})
.unwrap();
Object::assign(&style, &pos.into());
}
if let Some(element) = arrow_ref.get_untracked() {
let MiddlewareData {
arrow: ArrowPosition { x },
} = middleware_data;
let style = element.deref().style();
#[derive(serde::Serialize)]
struct Style {
left: String,
}
let pos = serde_wasm_bindgen::to_value(&Style {
left: format!("{x}px"),
})
.unwrap();
Object::assign(&style, &pos.into());
}
if let Some(menu_ref) = menu_ref.get_untracked() {
let class = if placement == "top" {
styles::contentTop
} else if placement == "bottom" {
styles::contentBottom
} else {
""
};
menu_ref.set_class_name(class);
}
});
});
if let (Some(reference), Some(floating)) = (&*reference_ref.read(), &*floating_ref.read()) {
let func = auto_update(
reference.into(),
floating.into(),
&callback,
JsValue::default(),
);
let func = FragileComfirmed::new(func);
let callback = FragileComfirmed::new(callback);
on_cleanup(move || {
func.call0(&JsValue::null()).unwrap();
drop(callback);
});
}
});
let menu = Arc::new(menu);
let float = move || {
let menu_clone = Arc::clone(&menu);
view! {
<Show when=is_open fallback=|| ()>
<div
class=styles::container
node_ref=floating_ref
style="position: absolute; width: max-content;"
>
<div
class=styles::arrow
node_ref=arrow_ref
style="position: absolute; pointer-events: none; bottom: 100%; transform: rotate(180deg);"
>
<svg width="20" height="20" viewBox="0 0 20 20">
<path stroke="none" d="M0,0 H20 L10,10 Q10,10 10,10 Z"></path>
<clipPath id=":rh:">
<rect x="0" y="0" width="20" height="20"></rect>
</clipPath>
</svg>
</div>
<div node_ref=menu_ref>
<div>{menu_clone(Box::new(close))}</div>
</div>
</div>
</Show>
}
};
let float = Arc::new(float);
let total = move || {
let float_clone = Arc::clone(&float);
if let Some(container) = menu_container.get() {
view! { <Portal mount=container.clone()>{float_clone()}</Portal> }.into_any()
} else {
float_clone().into_any()
}
};
view! { <>{button(Box::new(move |_| toggle()), reference_ref)} {total}</> }
}

27
src/app/select_one.rs Normal file
View File

@@ -0,0 +1,27 @@
use leptos::prelude::*;
use crate::app::selectable_menu_item::SelectableMenuItem;
#[component]
pub fn SelectOne<T, C, CH>(
name: String,
current_value: C,
this_value: T,
mut change_value: CH,
children: Children,
) -> impl IntoView
where
C: Fn() -> T + Send + 'static,
CH: FnMut() + Send + 'static,
T: PartialEq + Eq + Send + 'static,
{
view! {
<SelectableMenuItem
name=name
selected=move || { current_value() == this_value }
on_click=move |_| change_value()
>
{children()}
</SelectableMenuItem>
}
}

View File

@@ -0,0 +1,37 @@
use istyles::istyles;
use leptos::prelude::*;
use web_sys::MouseEvent;
use crate::app::{icon::checkmark_icon, menu_item::MenuItem};
istyles!(
styles,
"assets/module.postcss/selectable_menu_item.module.css.map"
);
#[component]
pub fn SelectableMenuItem<S, C>(
name: String,
selected: S,
on_click: C,
children: Children,
) -> impl IntoView
where
S: Fn() -> bool + Send + 'static,
C: FnMut(MouseEvent) + Send + 'static,
{
view! {
<MenuItem>
<button
class=move || { if selected() { styles::selected } else { styles::container } }
on:click=on_click
>
<div class=styles::header>
<span class=styles::checkmark>{checkmark_icon()}</span>
<span class=styles::name>{name}</span>
</div>
<div class=styles::description>{children()}</div>
</button>
</MenuItem>
}
}

192
src/app/state.rs Normal file
View File

@@ -0,0 +1,192 @@
use std::collections::HashSet;
use aceditor::Editor;
use leptos::tachys::dom::window;
use reactive_stores::Store;
use serde::{Deserialize, Serialize};
use crate::{FragileComfirmed, SQLightError, SQLiteStatementResult, WorkerHandle};
#[derive(Store, Serialize, Deserialize)]
pub struct GlobalState {
vfs: Vfs,
editor_config: EditorConfig,
orientation: Orientation,
theme: Theme,
keep_ctx: bool,
code: String,
// runtime state below
#[serde(skip)]
worker: Option<WorkerHandle>,
#[serde(skip)]
pub editor: Option<Editor>,
#[serde(skip)]
focus: Option<Focus>,
#[serde(skip)]
is_focused: bool,
#[serde(skip)]
opened_focus: HashSet<Focus>,
#[serde(skip)]
share_href: Option<String>,
#[serde(skip)]
show_something: bool,
#[serde(skip)]
output: Vec<SQLiteStatementResult>,
#[serde(skip)]
last_error: Option<FragileComfirmed<SQLightError>>,
}
impl Default for GlobalState {
fn default() -> Self {
Self {
editor_config: EditorConfig::default(),
code: String::new(),
focus: None,
show_something: false,
orientation: Orientation::Automatic,
theme: Theme::System,
output: Vec::new(),
vfs: Vfs::Memory,
keep_ctx: false,
share_href: None,
is_focused: false,
opened_focus: HashSet::new(),
worker: None,
editor: None,
last_error: None,
}
}
}
impl GlobalState {
pub fn load() -> Option<Self> {
let storage = window().local_storage().ok()??;
let value = storage.get("config").ok()??;
serde_json::from_str(&value).ok()
}
pub fn save(&self) {
if let Some(Err(e)) = window()
.local_storage()
.ok()
.flatten()
.map(|s| s.set_item("config", &serde_json::to_string(self).unwrap()))
{
log::error!("Faild to save config to localstorage: {e:?}");
}
}
}
#[derive(Serialize, Deserialize)]
pub struct EditorConfig {
pub keyboard: String,
pub theme: String,
}
impl Default for EditorConfig {
fn default() -> Self {
EditorConfig {
keyboard: "ace".into(),
theme: "github".into(),
}
}
}
impl GlobalState {
pub fn is_focus(&self) -> bool {
self.focus.is_some()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Vfs {
Memory,
OPFS,
}
impl Vfs {
pub fn value(&self) -> String {
match self {
Vfs::Memory => "Memory".into(),
Vfs::OPFS => "OPFS".into(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Focus {
Execute,
Share,
Status,
}
#[derive(Clone, Copy, Serialize, Deserialize)]
pub enum Theme {
System,
SystemLight,
SystemDark,
Light,
Dark,
}
impl Theme {
pub fn is_system(&self) -> bool {
matches!(self, Theme::System | Theme::SystemLight | Theme::SystemDark)
}
pub fn from_value(s: &str) -> Self {
match s {
"System" => Self::System,
"Light" => Self::Light,
"Dark" => Self::Dark,
_ => unreachable!(),
}
}
pub fn to_value(&self) -> String {
match self {
Theme::System | Theme::SystemLight | Theme::SystemDark => "System",
Theme::Light => "Light",
Theme::Dark => "Dark",
}
.into()
}
}
#[derive(Clone, Copy, Serialize, Deserialize)]
pub enum Orientation {
Automatic,
AutoHorizontal,
AutoVertical,
Horizontal,
Vertical,
}
impl Orientation {
pub fn is_auto(&self) -> bool {
matches!(
self,
Orientation::Automatic | Orientation::AutoVertical | Orientation::AutoHorizontal
)
}
pub fn from_value(s: &str) -> Self {
match s {
"Automatic" => Self::Automatic,
"Horizontal" => Self::Horizontal,
"Vertical" => Self::Vertical,
_ => unreachable!(),
}
}
pub fn to_value(&self) -> String {
match self {
Orientation::Automatic | Orientation::AutoVertical | Orientation::AutoHorizontal => {
"Automatic"
}
Orientation::Horizontal => "Horizontal",
Orientation::Vertical => "Vertical",
}
.into()
}
}

36
src/app/vfs_menu.rs Normal file
View File

@@ -0,0 +1,36 @@
use leptos::prelude::*;
use reactive_stores::Store;
use crate::app::{
GlobalState, GlobalStateStoreFields, Vfs, menu_group::MenuGroup, select_one::SelectOne,
};
#[component]
pub fn VfsMenu() -> impl IntoView {
let state = expect_context::<Store<GlobalState>>();
view! {
<MenuGroup title="Choose SQLite VFS".into()>
<SelectOne
name="Memory".into()
current_value=move || { *state.vfs().read() }
this_value=Vfs::Memory
change_value=move || {
*state.vfs().write() = Vfs::Memory;
}
>
"Data will be lost after refreshing."
</SelectOne>
<SelectOne
name="OPFS".into()
current_value=move || { *state.vfs().read() }
this_value=Vfs::OPFS
change_value=move || {
*state.vfs().write() = Vfs::OPFS;
}
>
"Persistent Storage."
</SelectOne>
</MenuGroup>
}
}

11
src/bin/app.rs Normal file
View File

@@ -0,0 +1,11 @@
use leptos::prelude::*;
use sqlight::{app::playground, setup_worker};
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen(main)]
async fn main() {
console_error_panic_hook::set_once();
console_log::init_with_level(log::Level::Debug).unwrap();
mount_to_body(playground(setup_worker().await));
}

42
src/bin/worker.rs Normal file
View File

@@ -0,0 +1,42 @@
use sqlight::{WorkerRequest, WorkerResponse, worker};
use tokio::sync::mpsc::UnboundedReceiver;
use wasm_bindgen::{JsCast, JsValue, prelude::Closure};
use wasm_bindgen_futures::spawn_local;
use web_sys::{DedicatedWorkerGlobalScope, MessageEvent};
fn main() {
console_error_panic_hook::set_once();
console_log::init_with_level(log::Level::Warn).unwrap();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<JsValue>();
let scope: DedicatedWorkerGlobalScope = JsValue::from(js_sys::global()).into();
spawn_local(execute_task(scope.clone(), rx));
let on_message = Closure::<dyn Fn(MessageEvent)>::new(move |ev: MessageEvent| {
tx.send(ev.data()).unwrap();
});
scope.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
scope
.post_message(&serde_wasm_bindgen::to_value(&WorkerResponse::Ready).unwrap())
.expect("Faild to send ready to window");
on_message.forget();
}
async fn execute_task(scope: DedicatedWorkerGlobalScope, mut rx: UnboundedReceiver<JsValue>) {
while let Some(request) = rx.recv().await {
let request = serde_wasm_bindgen::from_value::<WorkerRequest>(request).unwrap();
let resp = match request {
WorkerRequest::Open(options) => WorkerResponse::Open(worker::open(options).await),
WorkerRequest::Prepare(options) => WorkerResponse::Prepare(worker::prepare(options)),
WorkerRequest::Continue(id) => WorkerResponse::Continue(worker::r#continue(&id)),
WorkerRequest::StepOver(id) => WorkerResponse::StepOver(worker::step_over(&id)),
WorkerRequest::StepIn(id) => WorkerResponse::StepIn(worker::step_in(&id)),
WorkerRequest::StepOut(id) => WorkerResponse::StepOut(worker::step_out(&id)),
};
if let Err(err) = scope.post_message(&serde_wasm_bindgen::to_value(&resp).unwrap()) {
log::error!("Failed to send task to window: {resp:?}, {err:?}");
}
}
}

245
src/lib.rs Normal file
View File

@@ -0,0 +1,245 @@
pub mod app;
pub mod worker;
use aceditor::EditorError;
use app::{GlobalState, GlobalStateStoreFields};
use fragile::Fragile;
use leptos::prelude::*;
use reactive_stores::Store;
use serde_json::Value as JsonValue;
use std::{
ops::{Deref, DerefMut},
sync::Arc,
};
use tokio::sync::mpsc::UnboundedReceiver;
use wasm_bindgen::{JsCast, prelude::Closure};
use web_sys::{MessageEvent, Worker, WorkerOptions, WorkerType};
use serde::{Deserialize, Serialize};
type Result<T> = std::result::Result<T, WorkerError>;
/// A [`FragileComfirmed<T>`] wraps a non sendable `T` to be safely send to other threads.
///
/// Once the value has been wrapped it can be sent to other threads but access
/// to the value on those threads will fail.
pub struct FragileComfirmed<T> {
fragile: Fragile<T>,
}
unsafe impl<T> Send for FragileComfirmed<T> {}
unsafe impl<T> Sync for FragileComfirmed<T> {}
impl<T> FragileComfirmed<T> {
pub fn new(t: T) -> Self {
FragileComfirmed {
fragile: Fragile::new(t),
}
}
}
impl<T> Deref for FragileComfirmed<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
self.fragile.get()
}
}
impl<T> DerefMut for FragileComfirmed<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.fragile.get_mut()
}
}
pub const PERSIST_VFS: &str = "sqlight-sahpool";
#[derive(thiserror::Error, Debug)]
pub enum SQLightError {
#[error(transparent)]
Worker(#[from] WorkerError),
#[error(transparent)]
AceEditor(#[from] EditorError),
}
impl SQLightError {
pub fn new_worker(err: WorkerError) -> FragileComfirmed<Self> {
FragileComfirmed::new(Self::Worker(err))
}
pub fn new_ace_editor(err: EditorError) -> FragileComfirmed<Self> {
FragileComfirmed::new(Self::AceEditor(err))
}
}
#[derive(thiserror::Error, Debug, Serialize, Deserialize)]
pub enum WorkerError {
#[error(transparent)]
SQLite(#[from] SQLitendError),
#[error("Not found database by id")]
NotFound,
#[error("Execute sqlite with invaild state")]
InvaildState,
#[error("OPFS already opened")]
OpfsSAHPoolOpened,
#[error("OPFS unexpected error")]
OpfsSAHError,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum WorkerRequest {
Open(OpenOptions),
Prepare(PrepareOptions),
Continue(String),
StepOver(String),
StepIn(String),
StepOut(String),
}
#[derive(Debug, Serialize, Deserialize)]
pub enum WorkerResponse {
Ready,
Open(Result<String>),
Prepare(Result<()>),
Continue(Result<Vec<SQLiteStatementResult>>),
StepOver(Result<SQLiteStatementResult>),
StepIn(Result<()>),
StepOut(Result<SQLiteStatementResult>),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OpenOptions {
pub filename: String,
pub persist: bool,
}
impl OpenOptions {
pub fn uri(&self) -> String {
format!(
"file:{}?vfs={}",
self.filename,
if self.persist { PERSIST_VFS } else { "memvfs" }
)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PrepareOptions {
pub id: String,
pub sql: String,
pub clear_on_prepare: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InnerError {
pub code: i32,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum SQLiteStatementResult {
Finish,
Step(SQLiteStatementTable),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SQLiteStatementTable {
pub sql: String,
pub position: [usize; 2],
pub values: Option<SQLiteStatementValues>,
pub done: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SQLiteStatementValues {
pub columns: Vec<String>,
pub rows: Vec<Vec<JsonValue>>,
}
#[derive(thiserror::Error, Debug, Serialize, Deserialize)]
pub enum SQLitendError {
#[error("An error occurred while converting a string to a CString")]
ToCStr,
#[error("An error occurred while opening the DB: {0:?}")]
OpenDb(InnerError),
#[error("An error occurred while preparing stmt: {0:?}")]
Prepare(InnerError),
#[error("An error occurred while stepping to the next line")]
Step(InnerError),
#[error("An error occurred while getting column name: {0}")]
GetColumnName(String),
#[error("The text is not a utf8 string")]
Utf8Text,
#[error("The column type is not support: {0}")]
UnsupportColumnType(i32),
}
pub struct WorkerHandle(Worker);
impl WorkerHandle {
pub fn send_task(&self, req: WorkerRequest) {
if let Err(err) = self
.0
.post_message(&serde_wasm_bindgen::to_value(&req).unwrap())
{
log::error!("Failed to send task to worker: {req:?}, {err:?}");
}
}
}
unsafe impl Send for WorkerHandle {}
unsafe impl Sync for WorkerHandle {}
pub async fn setup_worker() -> (WorkerHandle, UnboundedReceiver<WorkerResponse>) {
let uri = "./worker_loader.js";
let opts = WorkerOptions::new();
opts.set_type(WorkerType::Module);
let worker = match Worker::new_with_options(uri, &opts) {
Ok(worker) => worker,
Err(err) => panic!("Failed to new setup worker: {err:?}"),
};
let notify = Arc::new(tokio::sync::Notify::new());
let wait = Arc::clone(&notify);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let on_message = Closure::<dyn Fn(MessageEvent)>::new(move |ev: MessageEvent| {
match serde_wasm_bindgen::from_value(ev.data()) {
Ok(WorkerResponse::Ready) => notify.notify_one(),
Ok(resp) => tx.send(resp).unwrap(),
Err(err) => log::error!("Failed to parse message {:?}", err),
}
});
worker.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
on_message.forget();
wait.notified().await;
(WorkerHandle(worker), rx)
}
pub async fn handle_state(state: Store<GlobalState>, mut rx: UnboundedReceiver<WorkerResponse>) {
while let Some(resp) = rx.recv().await {
match resp {
WorkerResponse::Ready => unreachable!(),
WorkerResponse::Open(result) => match result {
Ok(_) => (),
Err(err) => state.last_error().set(Some(SQLightError::new_worker(err))),
},
WorkerResponse::Prepare(result) => {
if let Err(err) = result {
state.last_error().set(Some(SQLightError::new_worker(err)));
}
}
WorkerResponse::Continue(result) => match result {
Ok(results) => state.output().set(results),
Err(err) => state.last_error().set(Some(SQLightError::new_worker(err))),
},
WorkerResponse::StepOver(_)
| WorkerResponse::StepIn(_)
| WorkerResponse::StepOut(_) => unimplemented!(),
}
}
}

191
src/worker/mod.rs Normal file
View File

@@ -0,0 +1,191 @@
mod sqlitend;
use crate::{OpenOptions, PERSIST_VFS, PrepareOptions, SQLiteStatementResult, WorkerError};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use sqlite_wasm_rs::{
export::{OpfsSAHPoolCfgBuilder, OpfsSAHPoolUtil},
mem_vfs::MemVfsUtil,
};
use sqlitend::{SQLiteDb, SQLitePreparedStatement, SQLiteStatements};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::OnceCell;
type Result<T> = std::result::Result<T, WorkerError>;
static DB_POOL: Lazy<Mutex<HashMap<String, SQLiteWorker>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static FS_UTIL: Lazy<FSUtil> = Lazy::new(|| FSUtil {
mem: MemVfsUtil::new(),
opfs: OnceCell::new(),
});
struct FSUtil {
mem: MemVfsUtil,
opfs: OnceCell<OpfsSAHPoolUtil>,
}
struct SQLiteWorker {
id: String,
db: Option<Arc<SQLiteDb>>,
open_options: OpenOptions,
state: SQLiteState,
}
enum SQLiteState {
Idie,
Prepared(PreparedState),
}
struct PreparedState {
stmts: SQLiteStatements,
prepared: Option<SQLitePreparedStatement>,
}
fn with_worker<F, T>(id: &str, mut f: F) -> Result<T>
where
F: FnMut(&mut SQLiteWorker) -> Result<T>,
{
f(DB_POOL.lock().get_mut(id).ok_or(WorkerError::NotFound)?)
}
pub async fn open(options: OpenOptions) -> Result<String> {
if let Some(worker) = DB_POOL.lock().get(&options.filename) {
return Ok(worker.id.clone());
}
if options.persist {
let util = FS_UTIL
.opfs
.get_or_try_init(|| async {
sqlite_wasm_rs::sahpool_vfs::install(
Some(
&OpfsSAHPoolCfgBuilder::new()
.directory(PERSIST_VFS)
.vfs_name(PERSIST_VFS)
.build(),
),
false,
)
.await
.map_err(|_| WorkerError::OpfsSAHPoolOpened)
})
.await?;
if util.get_capacity() - util.get_file_count() * 3 < 3 {
util.add_capacity(3)
.await
.map_err(|_| WorkerError::OpfsSAHError)?;
}
}
// FIXME: multi db support
let id = String::new();
let db = SQLiteDb::open(&options.uri())?;
let worker = SQLiteWorker {
id: id.clone(),
db: Some(db),
open_options: options,
state: SQLiteState::Idie,
};
DB_POOL.lock().insert(id.clone(), worker);
Ok(id)
}
pub fn prepare(options: PrepareOptions) -> Result<()> {
with_worker(&options.id, |worker| {
if options.clear_on_prepare {
worker.db.take();
let filename = &worker.open_options.filename;
let FSUtil { mem, opfs } = &*FS_UTIL;
if worker.open_options.persist {
if let Some(opfs) = opfs.get() {
opfs.unlink(filename)
.map_err(|_| WorkerError::OpfsSAHError)?;
}
} else {
mem.delete_db(filename);
}
worker.db = Some(SQLiteDb::open(&worker.open_options.uri())?);
}
let stmts = worker.db.as_ref().unwrap().prepare(&options.sql)?;
worker.state = SQLiteState::Prepared(PreparedState {
stmts,
prepared: None,
});
Ok(())
})
}
pub fn r#continue(id: &str) -> Result<Vec<SQLiteStatementResult>> {
with_worker(id, |worker| {
let state = std::mem::replace(&mut worker.state, SQLiteState::Idie);
let mut result = match state {
SQLiteState::Idie => return Err(WorkerError::InvaildState),
SQLiteState::Prepared(prepared_state) => {
let mut result = vec![];
if let Some(stmt) = prepared_state.prepared {
result.push(stmt.pack(stmt.get_all()?));
}
result.extend(prepared_state.stmts.stmts_result()?);
result
}
};
result.push(SQLiteStatementResult::Finish);
Ok(result)
})
}
pub fn step_over(id: &str) -> Result<SQLiteStatementResult> {
with_worker(id, |worker| match &mut worker.state {
SQLiteState::Idie => Err(WorkerError::InvaildState),
SQLiteState::Prepared(prepared_state) => {
if let Some(prepared) = &mut prepared_state.prepared {
if let Some(value) = prepared.get_one()? {
Ok(prepared.pack(Some(value)))
} else {
let done = prepared.pack(None);
prepared_state.prepared = None;
Ok(done)
}
} else if let Some(prepared) = prepared_state.stmts.prepare_next()? {
Ok(prepared.pack(prepared.get_all()?))
} else {
Ok(SQLiteStatementResult::Finish)
}
}
})
}
pub fn step_in(id: &str) -> Result<()> {
with_worker(id, |worker| {
match &mut worker.state {
SQLiteState::Idie => return Err(WorkerError::InvaildState),
SQLiteState::Prepared(prepared_state) => {
if prepared_state.prepared.is_some() {
return Err(WorkerError::InvaildState);
}
let prepared = prepared_state
.stmts
.prepare_next()?
.ok_or(WorkerError::InvaildState)?;
prepared_state.prepared = Some(prepared);
}
};
Ok(())
})
}
pub fn step_out(id: &str) -> Result<SQLiteStatementResult> {
with_worker(id, |worker| match &mut worker.state {
SQLiteState::Idie => Err(WorkerError::InvaildState),
SQLiteState::Prepared(prepared_state) => {
if let Some(prepared) = prepared_state.prepared.take() {
Ok(prepared.pack(prepared.get_all()?))
} else {
Err(WorkerError::InvaildState)
}
}
})
}

266
src/worker/sqlitend.rs Normal file
View File

@@ -0,0 +1,266 @@
use serde_json::Value as JsonValue;
use sqlite_wasm_rs::*;
use std::ffi::{CStr, CString};
use std::sync::Arc;
use std::sync::atomic::{self, AtomicBool};
use crate::{
InnerError, SQLiteStatementResult, SQLiteStatementTable, SQLiteStatementValues, SQLitendError,
};
type Result<T> = std::result::Result<T, SQLitendError>;
fn cstr(s: &str) -> Result<CString> {
CString::new(s).map_err(|_| SQLitendError::ToCStr)
}
fn sqlite_err(code: i32, db: *mut sqlite3) -> InnerError {
let message = unsafe {
let ptr = sqlite3_errmsg(db);
CStr::from_ptr(ptr).to_string_lossy().to_string()
};
InnerError { code, message }
}
pub struct SQLiteDb {
sqlite3: *mut sqlite3,
}
unsafe impl Send for SQLiteDb {}
unsafe impl Sync for SQLiteDb {}
impl SQLiteDb {
pub fn open(filename: &str) -> Result<Arc<Self>> {
let mut sqlite3 = std::ptr::null_mut();
let ret = unsafe {
sqlite3_open_v2(
cstr(filename)?.as_ptr().cast(),
&mut sqlite3 as *mut _,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
std::ptr::null(),
)
};
if ret != SQLITE_OK {
return Err(SQLitendError::OpenDb(sqlite_err(ret, sqlite3)));
}
Ok(Arc::new(Self { sqlite3 }))
}
pub fn prepare(self: &Arc<Self>, sql: &str) -> Result<SQLiteStatements> {
let sql = cstr(sql)?;
let tail = sql.as_ptr();
Ok(SQLiteStatements {
sql,
db: Arc::clone(self),
tail,
})
}
}
impl Drop for SQLiteDb {
fn drop(&mut self) {
unsafe {
sqlite3_close(self.sqlite3);
}
}
}
pub struct SQLiteStatements {
sql: CString,
db: Arc<SQLiteDb>,
tail: *const i8,
}
unsafe impl Send for SQLiteStatements {}
unsafe impl Sync for SQLiteStatements {}
impl SQLiteStatements {
pub fn prepare_next(&mut self) -> Result<Option<SQLitePreparedStatement>> {
if self.tail.is_null() {
return Ok(None);
}
let sqlite3 = self.db.sqlite3;
let mut stmt: *mut sqlite3_stmt = std::ptr::null_mut();
let mut tail = std::ptr::null();
let ret = unsafe {
sqlite3_prepare_v3(sqlite3, self.tail, -1, 0, &mut stmt as _, &mut tail as _)
};
if ret != SQLITE_OK {
return Err(SQLitendError::Prepare(sqlite_err(ret, sqlite3)));
}
let sql = unsafe { sqlite3_sql(stmt) };
if sql.is_null() {
return Ok(None);
}
let sql = unsafe { CStr::from_ptr(sql).to_string_lossy().to_string() };
let start_offset = self.tail as usize - self.sql.as_ptr() as usize;
let end_offset = start_offset + sql.len();
let position = [start_offset, end_offset];
self.tail = tail;
Ok(Some(SQLitePreparedStatement {
sql,
done: AtomicBool::new(false),
position,
sqlite3,
stmt,
}))
}
pub fn stmts_result(self) -> Result<Vec<SQLiteStatementResult>> {
let mut result = vec![];
for stmt in self {
let stmt = stmt?;
result.push(stmt.pack(stmt.get_all()?));
}
Ok(result)
}
}
impl Iterator for SQLiteStatements {
type Item = Result<SQLitePreparedStatement>;
fn next(&mut self) -> Option<Self::Item> {
self.prepare_next().transpose()
}
}
pub struct SQLitePreparedStatement {
sql: String,
position: [usize; 2],
done: AtomicBool,
sqlite3: *mut sqlite3,
stmt: *mut sqlite3_stmt,
}
unsafe impl Send for SQLitePreparedStatement {}
unsafe impl Sync for SQLitePreparedStatement {}
impl SQLitePreparedStatement {
/// Stepping to the next line
fn step(&self) -> Result<bool> {
let ret = unsafe { sqlite3_step(self.stmt) };
match ret {
SQLITE_DONE => {
self.done.store(true, atomic::Ordering::SeqCst);
Ok(false)
}
SQLITE_ROW => Ok(true),
code => Err(SQLitendError::Step(sqlite_err(code, self.sqlite3))),
}
}
pub fn pack(&self, values: Option<SQLiteStatementValues>) -> SQLiteStatementResult {
let values = SQLiteStatementTable {
sql: self.sql.clone(),
position: self.position,
done: self.done.load(atomic::Ordering::SeqCst),
values,
};
SQLiteStatementResult::Step(values)
}
pub fn get_all(&self) -> Result<Option<SQLiteStatementValues>> {
let mut values = match self.get_one()? {
Some(value) => value,
None => return Ok(None),
};
while let Some(value) = self.get_one()? {
for row in value.rows {
values.rows.push(row);
}
}
Ok(Some(values))
}
/// Get data for all columns of the current row
pub fn get_one(&self) -> Result<Option<SQLiteStatementValues>> {
if !self.step()? {
return Ok(None);
}
let column_count = unsafe { sqlite3_column_count(self.stmt) };
let mut column = Vec::with_capacity(column_count as usize);
let mut row = Vec::with_capacity(column_count as usize);
for col_ndx in 0..column_count {
// column_name as key
let (column_name, column_type) = unsafe {
let ptr = sqlite3_column_name(self.stmt, col_ndx);
if ptr.is_null() {
return Err(SQLitendError::GetColumnName(
"the column name is a null pointer, this shouldn't happen".into(),
));
}
let Ok(column_name) = CStr::from_ptr(ptr).to_str() else {
return Err(SQLitendError::GetColumnName(
"the column name is not a string, this shouldn't happen".into(),
));
};
(column_name, sqlite3_column_type(self.stmt, col_ndx))
};
// https://www.sqlite.org/c3ref/column_blob.html
let value = unsafe {
match column_type {
SQLITE_NULL => JsonValue::Null,
SQLITE_INTEGER => {
let number = sqlite3_column_int64(self.stmt, col_ndx);
JsonValue::from(number)
}
SQLITE_FLOAT => JsonValue::from(sqlite3_column_double(self.stmt, col_ndx)),
SQLITE_TEXT => {
let slice = {
let text = sqlite3_column_text(self.stmt, col_ndx);
// get text size, there may be problems if use as cstr
let len = sqlite3_column_bytes(self.stmt, col_ndx);
std::slice::from_raw_parts(text, len as usize)
};
// must be UTF-8 TEXT result
let Ok(text) = std::str::from_utf8(slice) else {
return Err(SQLitendError::Utf8Text);
};
JsonValue::from(text)
}
SQLITE_BLOB => {
let slice = {
let blob = sqlite3_column_blob(self.stmt, col_ndx);
let len = sqlite3_column_bytes(self.stmt, col_ndx);
std::slice::from_raw_parts(blob.cast::<u8>(), len as usize)
};
JsonValue::from(slice)
}
_ => return Err(SQLitendError::UnsupportColumnType(column_type)),
}
};
column.push(column_name.into());
row.push(value);
}
Ok(Some(SQLiteStatementValues {
columns: column,
rows: vec![row],
}))
}
}
impl Drop for SQLitePreparedStatement {
fn drop(&mut self) {
unsafe {
sqlite3_finalize(self.stmt);
};
}
}