дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
75773dd889
коміт
309bb48f2c
56 files changed
+913
-32
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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}"#));
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
19b672d86ff84edbbdb32ba06aefba08344e28b00310c09260d935a6fd2d6d92
|
||||
6c6c9e25269f0c7d11aae8fd6c7ff66f457c8883b4f9ff19ae75f96c445ca3ff
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
5916e03cba741eea838c99757a50b86ecae020a75dc856d0301a5afcd4beb437
|
||||
21fefee002afa17326ad22d9ff7e099500d07ab595035be3c6001ac15f4a08b0
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
cb9b6f980b976d04ff25289a7978fe6f0b1f77e99ac9bdbaa8fd0720e8306f29
|
||||
d7aa5bd1a9fefa6e6c35534dfd507d0f4aa872bb128ab6a01c35fb8abdc474b2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e399272912a4664c9ce307ff128ce2059a2276e31b7806e1553870c4041c27b5
|
||||
44145d1953b8b1c5a5bd57a15f88d4b6e86c1d7f456a2cb24758d19aca9e152e
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
5ad3afb9518e1e2d06e05b5ba5d7339b8aafa7d03502cad00bdacbfe73419775
|
||||
c324bb5591738282f63e90918be93cd7ac2a15f3c75fa105b60ead52a0504334
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
93840a797e8f6d86e3d75432395370f19e6a632444d8441b346b9db6c32cd6f9
|
||||
95d0e2a562f204030c3b2128cd84825cc5d6b6e85514a75f70fb847a1170b355
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
44a29b9c311e10a055625fc43940fb9ecd361639f8a11c992ac6817f39e95e36
|
||||
8687703fb1f2fb8d5ac187a6279a65d6cf1018a09262f6f5629bd4c903b26bdf
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
36a33de65d506d7753722d38aea7576722fba583ef615de502a5e341b781ef18
|
||||
aa2d8d38e49d10c03f6c639ce45b2a26b772c9f106a70547e94a9d46d8eef662
|
||||
Бінарний файл не відображається.
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
d7840fe3f2f2b21a2cfbdb7c874ebc3214d1a81e324853dd47b04d914434f050
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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 @@
|
||||
103165fc45ab6a995cdddd10848ae81f3f137438c1981375e6741185813ccdb3
|
||||
43d43f7a317c6fb8d5cca1b2a07f0e8dae05be6f5fcfc002527a409c519deb4f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e0845829cfaa2d8f471ccfd8a41726757443df6591e1e06be9e499d6781c02fe
|
||||
881d4ad1ac25784507c836c220fc09bdcdf45193f13538e928b8b3619b548552
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6490dcca17d4d87336c70bbf8fbfac90f711ed0079f43d16534703b179211069
|
||||
b029de3ddd2add1812b3071d407c59a97463a701b63c22232450b19a9f1d6557
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
aa807e1f712896e0ffe1f16115bd8ab991b59df5fcae73e97b6a40d6c94aaf8f
|
||||
4952cbef6094c109c3ca9637c8df7d47836b85632c450164f8f70b6b5f22d4e5
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
225ca906d21299fa45059c11d12468e6c3fc577aebb59441c51774a760e54a26
|
||||
34c03e490bea652f4e28fe2fb785dd9a2b0421f46c0015273793c14e074867f1
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
189e378fc52adfb058243feef4e100835600a1d4751adc23a262d13e703ef5e9
|
||||
d18f6687bc32a7a06c32ee81dd6e0c88e3acefd29934c9fa7271179d94fed194
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
2b2e162de966b670918b1bbe937c34cf9bef014834fb38be820ec25f20b09159
|
||||
444dc979be1b8194631303c3ad6399b876ee9229d1a8554681406fd39164f604
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6d13cd671dfed3c9c73be0e40b81cc5f757e4f8ebf6a9eecba0001fa8c314d47
|
||||
6f9412fce5e1a3d7453b0a1089d50544d49a3b1e8f022a5a1221ea597d2fd7dd
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a941b31f326427d41cf3e62373bc421d086789a0ac1cb8c3647eafafe29c5834
|
||||
a861d2b792c06dbac0476fa0b99321296da52f792ff340b1fd25de3b0e6422de
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
86428f64ea11d4863794595f12a9c100423725981d66d6f2a862191a0951fecf
|
||||
ccd7eab004cd2f767f6be21ce272f647e20695ceff8694293beff8ce8a06c646
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fc3b11c7b09f9a57c319af579efdd6f00d9ed51dccc6e86404c5164c6460d007
|
||||
8ae2a72ec23163926bac3914f4b27a429598982cd12f42f985df10e920942fdb
|
||||
@@ -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());
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, []));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Заблокувати користувача