feat(plugins): clickable column headers with sort events for plugin tables (#1785)

A plugin opts a table into click-to-sort by sending it as view body other(kind "table") with its sort; a header click comes back as view-event other(kind "sort", {column, descending}), so no released WIT type changes. The SDK builds and decodes both (Body::sortable_table, ViewEvent::sort). Headers are keyboard-operable, carry aria-sort and show the reported order with an arrow.

Co-authored-by: Inf1mum <[email protected]>
Цей коміт міститься в:
Vadim LazukтаInf1mum authored and GitHub committed 2026-11-07 21:09:18 +08:00
1 джерело 75773dd889
коміт 309bb48f2c
56 files changed
+913 -32

No files matched your search

+39
Переглянути файл
@@ -2114,6 +2114,45 @@ fn sample_plugin_tool_window_with_a_tree() {
}
}
/// The tables fixture sorts its own rows: a `table` body sent through `other`
/// opts the headers in, and a header click leaves as an `other` view-event
/// (kind `sort`) the plugin decodes and answers with the rows in the asked
/// order (plugins/README.md §10: released types stay, `other` carries new
/// cases).
#[test]
fn sortable_plugin_table_round_trips_sort_events() {
let f = fixture(Limits::default());
let info = f.install(&sample("tables"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let table = |f: &Fixture| match f.host.render_view("tables", "tables", None, f.inv()).unwrap().body {
ide_plugin::ViewBody::Table(t) => t,
other => panic!("{other:?}"),
};
// No sort yet: headers are clickable, rows in the plugin's own order.
let t = table(&f);
assert_eq!((t.columns.clone(), t.total_rows), (vec!["ext".to_owned(), "files".into()], 4));
assert_eq!((t.sortable, t.sort), (true, None), "a `table` through `other` opts the headers in");
assert_eq!(t.rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>(), ["rs", "md", "java", "toml"]);
f.take_events();
let sort = |f: &Fixture, descending| {
f.host.view_event("tables", "tables", ide_plugin::ViewEvent::Sort { column: 1, descending }, f.inv()).unwrap();
assert!(f.take_events().into_iter().any(
|(_, e)| matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "tables" && view == "tables")
));
table(f)
};
// The plugin reports the sort it ordered by, with the rows to match.
let t = sort(&f, false);
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: false }));
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["3", "9", "12", "20"]);
let t = sort(&f, true);
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: true }));
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["20", "12", "9", "3"]);
}
/// Regex Tester: an `html` body its script talks to; the plugin answers in
/// view messages, in UTF-16 offsets.
#[test]
+93 -5
Переглянути файл
@@ -12,8 +12,8 @@ use crate::types::{
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
};
use crate::types::{
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, commit_checks_bindings, details_bindings, hover_bindings, indexing_bindings, inlay_bindings,
@@ -1353,12 +1353,54 @@ fn service_tab_from_wit(t: wit_dt::Tab) -> ServiceTab {
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
}
/// A `table` body carried by the `other` case (the sortable variant, plugins/
/// README.md §10): the usual table fields as JSON plus `sort`, the column the
/// plugin orders by and the direction, or null. Anything unparsable degrades
/// like a body this host doesn't know.
fn sortable_table_from_json(data: &str) -> Option<ViewTable> {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Json {
#[serde(default)]
columns: Vec<String>,
#[serde(default)]
total_rows: u32,
#[serde(default)]
first_row: u32,
#[serde(default)]
rows: Vec<Vec<String>>,
#[serde(default)]
editable: bool,
#[serde(default)]
sort: Option<TableSort>,
}
let json: Json = serde_json::from_str(data).ok()?;
Some(ViewTable {
columns: json.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(),
total_rows: json.total_rows,
first_row: json.first_row,
rows: json
.rows
.into_iter()
.take(MAX_TABLE_ROWS as usize)
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
.collect(),
editable: json.editable,
sortable: true,
sort: json.sort,
})
}
fn view_from_wit(v: wit_vt::View) -> View {
let body = match v.body {
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
wit_vt::Body::Other(e) => {
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
}
wit_vt::Body::Other(e) => match e.kind.as_str() {
"table" => match sortable_table_from_json(&e.data) {
Some(t) => ViewBody::Table(t),
None => ViewBody::Message("This view needs a newer version of OpenIDEA (table).".into()),
},
_ => ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64))),
},
wit_vt::Body::Tree(items) => ViewBody::Tree(
items
.into_iter()
@@ -1417,6 +1459,9 @@ fn view_from_wit(v: wit_vt::View) -> View {
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
.collect(),
editable: t.editable,
// The plain body predates sortable headers; `other` kind `table` opts in.
sortable: false,
sort: None,
}),
wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)),
};
@@ -1450,6 +1495,12 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
ViewEvent::InsertRow { before } => W::InsertRow(before),
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
ViewEvent::Message { text } => W::Message(text),
// A sortable table's header click, as an `other` view-event (plugins/
// README.md §10): the SDKs decode the kinds they know.
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
kind: "sort".into(),
data: format!(r#"{{"column":{column},"descending":{descending}}}"#),
}),
}
}
@@ -1495,4 +1546,41 @@ mod tests {
let ViewBody::Form(fields) = form.body else { panic!("a form") };
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
}
#[test]
fn a_sortable_table_arrives_through_other() {
let view = |data: &str| wit_vt::View {
toolbar: Vec::new(),
body: wit_vt::Body::Other(wit_types::Extension { kind: "table".into(), data: data.into() }),
status: None,
};
let shown = view_from_wit(view(
r#"{"columns":["ext","files"],"totalRows":3,"firstRow":1,"rows":[["rs","2"],["md","1"]],
"editable":false,"sort":{"column":1,"descending":true}}"#,
));
let ViewBody::Table(t) = shown.body else { panic!("{shown:?}") };
assert_eq!(t.columns, ["ext".to_owned(), "files".into()]);
assert_eq!((t.total_rows, t.first_row, t.editable), (3, 1, false));
assert!(t.sortable, "a `table` through `other` opts the headers in");
assert_eq!(t.sort, Some(TableSort { column: 1, descending: true }));
// No sort yet: still clickable headers, just no arrow.
let fresh = view_from_wit(view(
r#"{"columns":["a"],"totalRows":0,"firstRow":0,"rows":[],"editable":false,"sort":null}"#,
));
let ViewBody::Table(t) = fresh.body else { panic!("{fresh:?}") };
assert!(t.sortable);
assert_eq!(t.sort, None);
// Broken JSON degrades like any body the host doesn't know.
let broken = view_from_wit(view("{not json"));
assert!(matches!(broken.body, ViewBody::Message(_)), "{broken:?}");
}
#[test]
fn a_sort_click_leaves_as_an_other_extension() {
let wit = event_to_wit_view(ViewEvent::Sort { column: 2, descending: true });
let wit_vt::ViewEvent::Other(e) = wit else { panic!("{wit:?}") };
assert_eq!((e.kind.as_str(), e.data.as_str()), ("sort", r#"{"column":2,"descending":true}"#));
}
}
+15
Переглянути файл
@@ -526,6 +526,14 @@ pub struct ViewField {
pub value: FieldValue,
}
/// A sortable table's order: the column and the direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TableSort {
pub column: u32,
pub descending: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ViewTable {
@@ -534,6 +542,10 @@ pub struct ViewTable {
pub first_row: u32,
pub rows: Vec<Vec<String>>,
pub editable: bool,
/// The plugin opted its headers in to click-to-sort (a `table` body through `other`).
pub sortable: bool,
/// The sort the plugin orders by (none: no arrow yet).
pub sort: Option<TableSort>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -614,4 +626,7 @@ pub enum ViewEvent {
InsertRow { before: u32 },
DeleteRows { rows: Vec<u32> },
Message { text: String },
// A sortable table's header click: the column and the direction (leaves
// as an `other` view-event, kind `sort`). See `TableSort`.
Sort { column: u32, descending: bool },
}
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
19b672d86ff84edbbdb32ba06aefba08344e28b00310c09260d935a6fd2d6d92
6c6c9e25269f0c7d11aae8fd6c7ff66f457c8883b4f9ff19ae75f96c445ca3ff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5916e03cba741eea838c99757a50b86ecae020a75dc856d0301a5afcd4beb437
21fefee002afa17326ad22d9ff7e099500d07ab595035be3c6001ac15f4a08b0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
cb9b6f980b976d04ff25289a7978fe6f0b1f77e99ac9bdbaa8fd0720e8306f29
d7aa5bd1a9fefa6e6c35534dfd507d0f4aa872bb128ab6a01c35fb8abdc474b2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e399272912a4664c9ce307ff128ce2059a2276e31b7806e1553870c4041c27b5
44145d1953b8b1c5a5bd57a15f88d4b6e86c1d7f456a2cb24758d19aca9e152e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
5ad3afb9518e1e2d06e05b5ba5d7339b8aafa7d03502cad00bdacbfe73419775
c324bb5591738282f63e90918be93cd7ac2a15f3c75fa105b60ead52a0504334
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
93840a797e8f6d86e3d75432395370f19e6a632444d8441b346b9db6c32cd6f9
95d0e2a562f204030c3b2128cd84825cc5d6b6e85514a75f70fb847a1170b355
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
44a29b9c311e10a055625fc43940fb9ecd361639f8a11c992ac6817f39e95e36
8687703fb1f2fb8d5ac187a6279a65d6cf1018a09262f6f5629bd4c903b26bdf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
36a33de65d506d7753722d38aea7576722fba583ef615de502a5e341b781ef18
aa2d8d38e49d10c03f6c639ce45b2a26b772c9f106a70547e94a9d46d8eef662
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a4f9b7a13a7c2aa1243359df865e659eb4b8f86fb6623d743dcc17988a10e183
eb5a36b16bf3ac0f98c1a10d8c7199d47b39d7448a5f4da2a5b47a111b7fd694
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-tables"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
Переглянути файл
@@ -0,0 +1,20 @@
[package]
name = "openidea-plugin-tables"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
d7840fe3f2f2b21a2cfbdb7c874ebc3214d1a81e324853dd47b04d914434f050
+18
Переглянути файл
@@ -0,0 +1,18 @@
# Test fixture: a tool window whose table the plugin sorts itself — the IDE
# makes the column headers clickable (`other` body, kind `table`) and sends
# `other` view-events, kind `sort`, which the plugin decodes and answers with
# the rows in the asked order.
id = "tables"
name = "Tables"
version = "0.1.0"
description = "Test fixture: a sortable table the plugin sorts itself."
authors = ["OpenIDEA contributors"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:tables"]
[[contributes.views]]
id = "tables"
title = "Tables"
icon = "plugin"
location = "right"
+71
Переглянути файл
@@ -0,0 +1,71 @@
//! Test fixture: a table of file-type counts the plugin sorts itself. The
//! headers are clickable ([`Body::sortable_table`], the plugin API's `other`
//! body, kind `table`), the clicks arrive as `other` view-events (kind
//! `sort`), and the rows render in the order the plugin was asked for.
use openidea_plugin::host;
use openidea_plugin::views::{Body, Table, TableSort, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
pub const VIEW: &str = "tables";
/// (extension, files) in no particular order, so sorting shows.
const ROWS: [(&str, &str); 4] = [("rs", "20"), ("md", "3"), ("java", "9"), ("toml", "12")];
#[derive(Default)]
pub struct Tables {
sort: Option<TableSort>,
}
impl Tables {
/// The rows in the order `sort` asks for (a real plugin would sort its
/// whole table and page it; the fixture is small enough to sort inline).
fn rows(&self) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = ROWS.iter().map(|(a, b)| vec![a.to_string(), b.to_string()]).collect();
if let Some(sort) = self.sort {
let by_number = |r: &Vec<String>| r[1].parse::<u32>().unwrap_or(0);
rows.sort_by(|a, b| {
let (x, y) = (by_number(a), by_number(b));
if sort.descending { y.cmp(&x) } else { x.cmp(&y) }
});
}
rows
}
}
impl Plugin for Tables {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(format!("command {id}")))
}
}
impl Views for Tables {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
let table = Table {
columns: vec!["ext".into(), "files".into()],
total_rows: ROWS.len() as u32,
first_row: 0,
rows: self.rows(),
editable: false,
};
Ok(View::new(Body::sortable_table(&table, self.sort)))
}
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
// The host may have encoded the click for a newer SDK: the typed
// helper reads the `other` kind `sort` this SDK knows.
if let Some(sort) = event.sort() {
self.sort = Some(sort);
host::refresh_view(VIEW);
}
Ok(())
}
}
register_plugin!(Tables, views);
+6
Переглянути файл
@@ -603,6 +603,12 @@ collapse of items, form fields, table cells, inserted and deleted rows, and
`html` messages. A tree is a flat list whose items name their `parent`; an
`expandable` item gets its children when it is expanded. A table gets only
the rows it shows (`viewport`): report `total_rows` and return those.
For a table the user can sort by clicking a header, build it with
`Body::sortable_table(&table, sort)`: it travels as a `table` through `other`
(so IDEs and plugins of any API version keep working), the IDE makes the
column headers clickable and asks for a column and direction with
`ViewEvent::sort()` — sort your rows and render again; the sort you report
shows as an arrow in the header.
Bodies, field types and view events added by later APIs travel as `Other {
kind, data }`: an IDE that doesn't know a body says it needs updating, and
leaves out a field type it doesn't know.
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
103165fc45ab6a995cdddd10848ae81f3f137438c1981375e6741185813ccdb3
43d43f7a317c6fb8d5cca1b2a07f0e8dae05be6f5fcfc002527a409c519deb4f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e0845829cfaa2d8f471ccfd8a41726757443df6591e1e06be9e499d6781c02fe
881d4ad1ac25784507c836c220fc09bdcdf45193f13538e928b8b3619b548552
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6490dcca17d4d87336c70bbf8fbfac90f711ed0079f43d16534703b179211069
b029de3ddd2add1812b3071d407c59a97463a701b63c22232450b19a9f1d6557
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
aa807e1f712896e0ffe1f16115bd8ab991b59df5fcae73e97b6a40d6c94aaf8f
4952cbef6094c109c3ca9637c8df7d47836b85632c450164f8f70b6b5f22d4e5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
225ca906d21299fa45059c11d12468e6c3fc577aebb59441c51774a760e54a26
34c03e490bea652f4e28fe2fb785dd9a2b0421f46c0015273793c14e074867f1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
189e378fc52adfb058243feef4e100835600a1d4751adc23a262d13e703ef5e9
d18f6687bc32a7a06c32ee81dd6e0c88e3acefd29934c9fa7271179d94fed194
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2b2e162de966b670918b1bbe937c34cf9bef014834fb38be820ec25f20b09159
444dc979be1b8194631303c3ad6399b876ee9229d1a8554681406fd39164f604
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6d13cd671dfed3c9c73be0e40b81cc5f757e4f8ebf6a9eecba0001fa8c314d47
6f9412fce5e1a3d7453b0a1089d50544d49a3b1e8f022a5a1221ea597d2fd7dd
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a941b31f326427d41cf3e62373bc421d086789a0ac1cb8c3647eafafe29c5834
a861d2b792c06dbac0476fa0b99321296da52f792ff340b1fd25de3b0e6422de
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
86428f64ea11d4863794595f12a9c100423725981d66d6f2a862191a0951fecf
ccd7eab004cd2f767f6be21ce272f647e20695ceff8694293beff8ce8a06c646
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fc3b11c7b09f9a57c319af579efdd6f00d9ed51dccc6e86404c5164c6460d007
8ae2a72ec23163926bac3914f4b27a429598982cd12f42f985df10e920942fdb
+17
Переглянути файл
@@ -30,6 +30,23 @@ pub fn position(text: &str, at: usize) -> Position {
Position { line, character }
}
/// The text as a JSON string literal's contents (`"`, `\`, control characters).
pub fn escape_json(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c < ' ' => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
/// The text of a JSON string literal's contents (`\"`, `\\`, `\n`, `\uXXXX`).
pub fn unescape_json(s: &str) -> String {
let mut out = String::with_capacity(s.len());
+138
Переглянути файл
@@ -148,6 +148,15 @@ pub struct Table {
pub editable: bool,
}
/// The column a table's rows are ordered by, and the direction: what
/// [`Body::sortable_table`] reports and a header click asks for
/// ([`ViewEvent::sort`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TableSort {
pub column: u32,
pub descending: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Body {
@@ -167,6 +176,34 @@ pub enum Body {
},
}
impl Body {
/// A table whose column headers the IDE makes clickable: the usual
/// [`Table`] fields plus the sort the plugin orders by (none: no arrow
/// yet), carried by the `other` body case (plugins/README.md §10), so
/// IDEs and plugins of any API version keep working. The plugin sorts its
/// own rows; a click asks for a column and direction with
/// [`ViewEvent::sort`], and [`crate::host::refresh_view`] shows the new
/// order.
pub fn sortable_table(table: &Table, sort: Option<TableSort>) -> Self {
let mut data = String::from(r#"{"columns":"#);
json_list(&mut data, table.columns.iter().map(String::as_str));
data.push_str(&format!(",\"totalRows\":{},\"firstRow\":{},\"rows\":[", table.total_rows, table.first_row));
for (i, row) in table.rows.iter().enumerate() {
if i > 0 {
data.push(',');
}
json_list(&mut data, row.iter().map(String::as_str));
}
data.push_str(&format!("],\"editable\":{},\"sort\":", table.editable));
match sort {
Some(s) => data.push_str(&format!(r#"{{"column":{},"descending":{}}}"#, s.column, s.descending)),
None => data.push_str("null"),
}
data.push('}');
Self::Other { kind: "table".into(), data }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct View {
pub toolbar: Vec<Button>,
@@ -235,6 +272,63 @@ pub enum ViewEvent {
},
}
impl ViewEvent {
/// The click on a sortable table's header ([`Body::sortable_table`]): the
/// column and the direction to order it by (`other` view-event, kind
/// `sort`; the first click asks ascending, a second on the same column
/// reverses). `None` for any other event, or one whose data this SDK
/// doesn't read.
pub fn sort(&self) -> Option<TableSort> {
let Self::Other { kind, data } = self else { return None };
if kind != "sort" {
return None;
}
Some(TableSort { column: json_number(data, "column")?, descending: json_bool(data, "descending")? })
}
}
/// The value of `"key":` in the host's flat JSON, up to the next `,` or `}`.
fn json_value<'a>(data: &'a str, key: &str) -> Option<&'a str> {
let needle = format!(r#""{key}""#);
let mut from = 0;
while let Some(i) = data[from..].find(&needle) {
let after = data[from + i + needle.len()..].trim_start();
if let Some(rest) = after.strip_prefix(':') {
let rest = rest.trim_start();
let end = rest.find([',', '}']).unwrap_or(rest.len());
return Some(rest[..end].trim());
}
from += i + needle.len();
}
None
}
fn json_number(data: &str, key: &str) -> Option<u32> {
json_value(data, key)?.parse().ok()
}
fn json_bool(data: &str, key: &str) -> Option<bool> {
match json_value(data, key)? {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
/// A JSON string array.
fn json_list<'a>(out: &mut String, items: impl Iterator<Item = &'a str>) {
out.push('[');
for (i, item) in items.enumerate() {
if i > 0 {
out.push(',');
}
out.push('"');
out.push_str(&crate::text::escape_json(item));
out.push('"');
}
out.push(']');
}
/// Content of the plugin's declared views and custom editors.
pub trait Views {
fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error>;
@@ -243,3 +337,47 @@ pub trait Views {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_sortable_table_becomes_the_other_table_body() {
let table = Table {
columns: vec!["ext".into(), "files".into()],
total_rows: 2,
rows: vec![vec!["rs".into(), "2".into()], vec!["say \"hi\"".into(), "1".into()]],
..Table::default()
};
let body = Body::sortable_table(&table, Some(TableSort { column: 1, descending: true }));
let Body::Other { kind, data } = body else { panic!("{body:?}") };
assert_eq!(kind, "table");
assert_eq!(
data,
concat!(
r#"{"columns":["ext","files"],"totalRows":2,"firstRow":0,"rows":[["rs","2"],["say \"hi\"","1"]],"#,
r#""editable":false,"sort":{"column":1,"descending":true}}"#
)
);
// No sort yet: the headers are still clickable.
let body = Body::sortable_table(&table, None);
let Body::Other { data, .. } = body else { panic!("{body:?}") };
assert!(data.ends_with(r#""editable":false,"sort":null}"#), "{data}");
}
#[test]
fn a_sort_click_decodes_from_the_other_event() {
let sort = |kind: &str, data: &str| ViewEvent::Other { kind: kind.into(), data: data.into() }.sort();
assert_eq!(sort("sort", r#"{"column":3,"descending":true}"#), Some(TableSort { column: 3, descending: true }));
assert_eq!(
sort("sort", r#"{"descending":false,"column": 0 }"#),
Some(TableSort { column: 0, descending: false })
);
assert_eq!(sort("sort", r#"{"column":1}"#), None, "no direction: not a sort");
assert_eq!(sort("sort", "{not json"), None);
assert_eq!(sort("other-thing", r#"{"column":1,"descending":false}"#), None, "another kind");
assert_eq!(ViewEvent::Button("x".into()).sort(), None);
}
}
+11 -1
Переглянути файл
@@ -7708,6 +7708,12 @@ export type SyntaxNode = {
hasError: boolean,
};
/** A sortable table's order: the column and the direction. */
export type TableSort = {
column: number,
descending: boolean,
};
/** A long-running background task shown in the status bar. */
export type TaskId = number;
@@ -8067,7 +8073,7 @@ export type ViewButton = {
};
/** A user action in a view. */
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string };
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean };
export type ViewField = {
key: string,
@@ -8088,6 +8094,10 @@ export type ViewTable = {
firstRow: number,
rows: string[][],
editable: boolean,
/** The plugin opted its headers in to click-to-sort (a `table` body through `other`). */
sortable: boolean,
/** The sort the plugin orders by (none: no arrow yet). */
sort: TableSort | null,
};
export type ViewTreeItem = {
+83 -1
Переглянути файл
@@ -400,7 +400,15 @@ describe("PluginView", () => {
toolbar: [],
body: {
kind: "table",
value: { columns: ["name", "city"], totalRows: 1, firstRow: 0, rows: [["Ada", city]], editable: true },
value: {
columns: ["name", "city"],
totalRows: 1,
firstRow: 0,
rows: [["Ada", city]],
editable: true,
sortable: false,
sort: null,
},
},
status: null,
});
@@ -419,6 +427,80 @@ describe("PluginView", () => {
expect(await screen.findByText("Paris")).toBeInTheDocument();
});
it("sorts a sortable table from its column headers, inverting on a second click", async () => {
const events: ViewEvent[] = [];
let sort: { column: number; descending: boolean } | null = null;
const table = (): View => ({
toolbar: [],
body: {
kind: "table",
value: {
columns: ["ext", "files"],
totalRows: 1,
firstRow: 0,
rows: [["rs", "2"]],
editable: false,
sortable: true,
sort,
},
},
status: null,
});
setPluginsBackend(backendWith(table, events));
const user = userEvent.setup();
const { container } = render(<PluginView pluginId="ext" view="counts" />);
const header = await screen.findByRole("columnheader", { name: "files" });
await user.click(header);
await waitFor(() => expect(events).toContainEqual({ kind: "sort", column: 1, descending: false }));
sort = { column: 1, descending: false };
bumpView("ext", "counts");
await waitFor(() =>
expect(screen.getByRole("columnheader", { name: "files" })).toHaveAttribute("aria-sort", "ascending"),
);
expect(container.querySelector('[aria-sort="ascending"] svg')).not.toBeNull();
await user.click(screen.getByRole("columnheader", { name: "files" }));
await waitFor(() => expect(events).toContainEqual({ kind: "sort", column: 1, descending: true }));
sort = { column: 1, descending: true };
bumpView("ext", "counts");
await waitFor(() =>
expect(screen.getByRole("columnheader", { name: "files" })).toHaveAttribute("aria-sort", "descending"),
);
// Another column starts over at ascending.
await user.click(screen.getByRole("columnheader", { name: "ext" }));
await waitFor(() => expect(events).toContainEqual({ kind: "sort", column: 0, descending: false }));
expect(events.filter((e) => e.kind === "sort")).toHaveLength(3);
});
it("keeps plain table headers inert", async () => {
const events: ViewEvent[] = [];
const table = (): View => ({
toolbar: [],
body: {
kind: "table",
value: {
columns: ["name", "city"],
totalRows: 1,
firstRow: 0,
rows: [["Ada", "London"]],
editable: false,
sortable: false,
sort: null,
},
},
status: null,
});
setPluginsBackend(backendWith(table, events));
const user = userEvent.setup();
render(<PluginView pluginId="csv" view="table" />);
await user.click(await screen.findByRole("columnheader", { name: "city" }));
expect(events).toHaveLength(0);
});
it("keeps an HTML body loaded across theme switches and posts the new theme to it", async () => {
const html: View = { toolbar: [], body: { kind: "html", value: "<p>chart</p>" }, status: null };
setPluginsBackend(backendWith(() => html, []));
+17
Переглянути файл
@@ -162,6 +162,23 @@
border-right: 1px solid var(--border);
}
/* A sortable table's header: clickable, the sorted one marked by an arrow. */
.headerCell {
cursor: pointer;
user-select: none;
}
.headerCell:hover,
.headerCell:focus-visible {
background: var(--bg-hover);
}
.sortIcon {
display: inline-block;
margin-left: 4px;
vertical-align: -2px;
}
.tableEmpty {
padding: 32px 12px;
color: var(--fg-muted);
+31 -5
Переглянути файл
@@ -384,6 +384,13 @@ function TableBody({
if (table.editable) showContextMenu(e, items);
};
// A sortable table (`table` through the plugin API's `other` body case):
// the plugin sorts its own rows, so a header click only asks it to — the
// first click ascending, a second on the same column reversed.
const sort = table.sortable ? table.sort : null;
const sortColumn = (column: number) =>
send({ kind: "sort", column, descending: sort?.column === column ? !sort.descending : false });
const rows: React.ReactNode[] = [];
for (let r = Math.max(first, table.firstRow); r < Math.min(shownEnd, loadedEnd); r++) {
const cells = table.rows[r - table.firstRow] ?? [];
@@ -446,11 +453,30 @@ function TableBody({
<div className={styles.tableCanvas} style={{ height: (table.totalRows + 1) * ROW_HEIGHT }}>
<div className={`${styles.tableRow} ${styles.tableHeader}`} role="row" aria-rowindex={1}>
<div className={styles.rowNumber} />
{table.columns.map((c, i) => (
<div key={i} role="columnheader" className={styles.cell} title={c}>
{c}
</div>
))}
{table.columns.map((c, i) => {
const sorted = sort?.column === i;
return (
<div
key={i}
role="columnheader"
className={table.sortable ? `${styles.cell} ${styles.headerCell}` : styles.cell}
title={c}
aria-sort={sorted ? (sort.descending ? "descending" : "ascending") : undefined}
tabIndex={table.sortable ? 0 : undefined}
onClick={table.sortable ? () => sortColumn(i) : undefined}
onKeyDown={
table.sortable
? (e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), sortColumn(i))
: undefined
}
>
{c}
{sorted && (
<Icon name={sort.descending ? "chevron-down" : "chevron-up"} size={12} className={styles.sortIcon} />
)}
</div>
);
})}
</div>
{rows}
</div>