diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index a5ff31292..e961f51b7 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -3569,6 +3569,30 @@ fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capab } } +#[test] +fn plugin_readonly_fields_and_table_row_extensions_round_trip() { + let f = fixture(Limits::default()); + f.install(&sample("tables")); + let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap(); + let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") }; + assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into())); + assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false }); + assert_eq!( + fields[2].value, + ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true } + ); + assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_))); + for (event, status) in [ + (ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"), + (ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"), + ] { + f.take_events(); + f.host.view_event("tables", "tables", event, f.inv()).unwrap(); + assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables"))); + assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status)); + } +} + #[test] fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() { let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() }); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 28991a97b..35f154751 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1619,8 +1619,23 @@ fn view_from_wit(v: wit_vt::View) -> View { options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(), selected: clip(selected, MAX_TITLE), }, - // A field type of a newer API: left out. - wit_vt::FieldValue::Other(_) => return None, + wit_vt::FieldValue::Other(e) => match e.kind.as_str() { + "label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)), + "readonly-text" => { + #[derive(serde::Deserialize)] + struct ReadonlyText { + text: String, + #[serde(default)] + multiline: bool, + } + let value: ReadonlyText = serde_json::from_str(&e.data).ok()?; + FieldValue::ReadonlyText { + text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }), + multiline: value.multiline, + } + } + _ => return None, + }, }; Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value }) }) @@ -1673,6 +1688,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), + ViewEvent::TableSelect { row } => { + W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) }) + } + ViewEvent::TableActivate { row } => { + W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) }) + } // 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 { @@ -1686,6 +1707,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { mod tests { use super::*; + #[test] + fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() { + let fields = [ + ("label", r#""Title \"quoted\"""#), + ("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#), + ("readonly-text", r#"{"text":"author"}"#), + ("label", "{}"), + ("readonly-text", r#"{"text":false}"#), + ("future", "null"), + ] + .into_iter() + .enumerate() + .map(|(i, (kind, data))| wit_vt::Field { + key: i.to_string(), + label: "Field".into(), + value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }), + }) + .collect(); + let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None }); + let ViewBody::Form(fields) = view.body else { panic!("form") }; + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into())); + assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true }); + assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false }); + } + + #[test] + fn table_row_events_leave_through_released_extension_case() { + for (event, kind) in [ + (ViewEvent::TableSelect { row: 51 }, "table-select"), + (ViewEvent::TableActivate { row: 51 }, "table-activate"), + ] { + let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") }; + assert_eq!(extension.kind, kind); + assert_eq!(extension.data, r#"{"row":51}"#); + } + } + #[cfg(windows)] #[test] fn provider_and_resolved_action_paths_match_ipc_events() { diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 357f459aa..d12365203 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -527,6 +527,8 @@ pub struct ViewListItem { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", tag = "kind", content = "value")] pub enum FieldValue { + Label(String), + ReadonlyText { text: String, multiline: bool }, Text(String), Multiline(String), Checkbox(bool), @@ -644,6 +646,8 @@ pub enum ViewEvent { // 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 }, + TableSelect { row: u32 }, + TableActivate { row: u32 }, } /// A plugin's HTTP route declaration in a project. diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 80f5f73b6..51d9c12f7 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -627be971ecc31efd268258eff214ed08c671c2a158677b773de9d5be462b17c4 +73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index d1fd3db1c..1b3352545 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -56683bb1e102025801610a2d61ce393f03bde9a3e5b5e52dde2ff272b3f10662 +4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 37482b7f6..b01c003f5 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 5b3b1f113..41ad8e0bb 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -d6a10bd1a97e7da44338fc9a28f5a64054c9269f553003298f816bb114f42a88 +83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 4eb5d092c..f07847b94 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -21d7a4cf9348568b7cb2b565c6e26a727165319bd685147fcedd3a8465565990 +bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index f5a605f66..ea69603ef 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -636846f46f27ef901fde5c8767991fdc1060c86d7f90a12b0cb08933a2e60b37 +1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 6debc7b53..ac690f4be 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -a35dfaecf8d76d3ca95c80e810be26781671f58a48b50374e2656cdd6959cdf2 +17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index bf157c042..ec949d20b 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 3afdf228b..228a0f7ef 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -a8b92a0ee9b46d8d414457ff287d6092e85d4536e913d146aa49662814ff1836 +85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 5c04746d3..3b42c446a 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -2fa3d6146a99eee79d4aec12bf8777369ae5cc76c3f7d6a8c254d5a27d58eeb9 +ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6 diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 56dee4cff..00cae590f 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -c14d91010021e3b35f6ae29a2a327bb3ad66087f07cfd744814443862c0050cc +66d958477027d1fc085f4096d082ddb71a26e173c4a93cfda42d3e770f27b25d diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index cffc162fd..dc17a0146 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -e9b6c10973ce62057eb3c92a747bcb27fdef1cfb6dd1d8681aa0035d3a3b5fa2 +98ab76da97224a698cb20a032d304e545ee168da97273f471030a74288b0f018 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 207373302..fe09a781d 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -a873a709240a3d24c45481b220ac283103f10645166952cb5dcc809f7995e085 +33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 265487df1..8b2e2ce3e 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index c4a7d7e70..0f9e3ec65 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -8801a468856e0e4783848f89d51d6d6f45426e6cadf2b2532518500ff23aa6f7 +92654b80a977c272264c15d8b608e8bdac6c5d5612a5da2707bab156e655f615 diff --git a/crates/ide-plugin/tests/fixtures/tables/plugin.toml b/crates/ide-plugin/tests/fixtures/tables/plugin.toml index b6f0ff762..08d7e23e4 100644 --- a/crates/ide-plugin/tests/fixtures/tables/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/tables/plugin.toml @@ -7,12 +7,27 @@ name = "Tables" version = "0.1.0" description = "Test fixture: a sortable table the plugin sorts itself." authors = ["OpenIDEA contributors"] -engines.openidea = "^1.0" +engines.openidea = "^1.3" wasm = "dist/plugin.wasm" -activation = ["on-view:tables"] +activation = ["on-view:tables", "on-view:fields", "on-command:inspect"] [[contributes.views]] id = "tables" title = "Tables" icon = "plugin" location = "right" + +[[contributes.views]] +id = "fields" +title = "Read-only Fields" +icon = "plugin" +location = "right" + +[[contributes.commands]] +id = "inspect" +title = "Inspect Row" + +[[contributes.menus]] +menu = "viewItem.context" +view = "tables" +command = "inspect" diff --git a/crates/ide-plugin/tests/fixtures/tables/src/lib.rs b/crates/ide-plugin/tests/fixtures/tables/src/lib.rs index 55f60c2cd..6b8930748 100644 --- a/crates/ide-plugin/tests/fixtures/tables/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/tables/src/lib.rs @@ -4,7 +4,7 @@ //! `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::views::{Body, Field, FieldValue, Table, TableSort, View, ViewEvent, Viewport, Views}; use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; pub const VIEW: &str = "tables"; @@ -15,6 +15,8 @@ const ROWS: [(&str, &str); 4] = [("rs", "20"), ("md", "3"), ("java", "9"), ("tom #[derive(Default)] pub struct Tables { sort: Option, + status: Option, + note: Option, } impl Tables { @@ -34,13 +36,34 @@ impl Tables { } impl Plugin for Tables { - fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result, Error> { + if id == "inspect" { + self.status = Some(format!("context:{}", arg.unwrap_or(""))); + host::refresh_view(VIEW); + return Ok(self.status.clone()); + } Err(Error::NotFound(format!("command {id}"))) } } impl Views for Tables { fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + if view == "fields" { + return Ok(View::new(Body::Form(vec![ + Field::new("title", "Title", FieldValue::label("Merge request #42")), + Field::new("author", "Author", FieldValue::readonly_text("Ada Lovelace", false)), + Field::new( + "description", + "Description", + FieldValue::readonly_text("Ready for review\nAll checks passed", true), + ), + Field::new( + "editable", + "Editable note", + FieldValue::Text(self.note.clone().unwrap_or_else(|| "Edit me".into())), + ), + ]))); + } if view != VIEW { return Err(Error::NotFound(view.into())); } @@ -51,13 +74,32 @@ impl Views for Tables { rows: self.rows(), editable: false, }; - Ok(View::new(Body::sortable_table(&table, self.sort))) + let mut view = View::new(Body::sortable_table(&table, self.sort)); + view.status = self.status.clone(); + Ok(view) } fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + if view == "fields" { + if let ViewEvent::Field(key, value) = event { + if key == "editable" { + self.note = Some(value); + host::refresh_view("fields"); + } + } + return Ok(()); + } if view != VIEW { return Err(Error::NotFound(view.into())); } + if let Some(row) = event.table_select() { + self.status = Some(format!("select:{row}")); + host::refresh_view(VIEW); + } + if let Some(row) = event.table_activate() { + self.status = Some(format!("activate:{row}")); + host::refresh_view(VIEW); + } // 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() { diff --git a/plugins/README.md b/plugins/README.md index 4dd12940f..fa14654b0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -639,6 +639,26 @@ For a table the user can sort by clicking a header, build it with 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. +API 1.3 adds read-only form values without changing the released field record: +`FieldValue::label(text)` uses `other { kind: "label", data }`, where `data` +is a JSON string. `FieldValue::readonly_text(text, multiline)` uses kind +`readonly-text` with JSON `{ "text": "…", "multiline": false }` (the flag +defaults to false). Labels render as plain selectable values; read-only text +keeps the single-line or multiline text control and never sends field events. + +Table row clicks and Space send `other` kind `table-select`; double-click +and Enter send `table-activate`, both with JSON `{ "row": 0 }`. +`ViewEvent::table_select()` and `table_activate()` decode the absolute, +zero-based index, including `first_row` for paged tables. These work for both +plain tables and the same `other` table shape used by sortable tables above; +no new table shape is needed. Editable cells still double-click to edit; +activate their row with Enter on the focused row or double-click its row number. +`viewItem.context` menus also work on table rows: the command argument and +`viewItem` context key are the decimal row index, e.g. `"0"`. Editable tables +keep their insert/delete entries alongside contributed commands. After sorting +or changing data, plugins resolve row indices against their current row order. +Declare `engines.openidea = "^1.3"` when relying on these additions. + 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. diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index f7059eb82..4c26aa7a6 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -d46e07036f0f59c18ca54affbca6446c320476998f3e92199cd93ed43dd725d9 +abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 47b51c2b6..3937291d0 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -c28b0da5a21fc0fc5dd543d01eaeeed4f7bbd89a5d28b95ce911a1c092209adc +7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 7b5db1cf8..46f165e97 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 00ba69d61..a994d32a5 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -f4258172c7a8278d00f5da9d76420380200d8b0d8bc387ceafab30703cbdac4a +d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 7f5ca161e..0ff05c2b1 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -648b030335ecfdf82651561fc5e71edc9f9d4319ae4bfb56d1e6d7e1f029d781 +2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index f1e73887b..99dff7b05 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -f7bbe6097f18377a79f39ba2369140e24eff65501e78be94e3420c6667362743 +4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 4cd2641dd..37f95f763 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -936d6e2c74f87cc1d95c52ea674716d934df917c12fb8431989f8dc64989b174 +d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index bf49a1220..f8e01f0aa 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -182af35d880a9adfa2566a42b62db6cf69f7d2a5157e0428228b649864e9417c +582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index d2cba9a41..e6a1f675b 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -f750b060f9a4217da81395aaa9875811d61d087e8d7fef1a0d7b46e3d08cd4a8 +3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 3c9407408..81d96c857 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 6b1a93310..3cd7203d1 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -4edc8aad4feb31205d567e303a18f9d0d4969bc65dc8f3699b46cd05b93d8868 +2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 33245bb32..b9561d44b 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -e1e58cd1ee112923dfc1debe86f8feb18c1a4e9f58490b5e371ee0272cad6ced +dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47 diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 5813764a4..c90232e94 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -318e264055acfd1d10491dfea784fdb2a034d3b4da8f7583e81720e21ea1947c +8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340 diff --git a/sdk/rust/openidea-plugin/src/views.rs b/sdk/rust/openidea-plugin/src/views.rs index 9acf2b9bd..bb6729c8d 100644 --- a/sdk/rust/openidea-plugin/src/views.rs +++ b/sdk/rust/openidea-plugin/src/views.rs @@ -125,6 +125,21 @@ pub enum FieldValue { }, } +impl FieldValue { + /// A plain read-only value, carried by `other` kind `label` (API 1.3). + pub fn label(text: &str) -> Self { + Self::Other { kind: "label".into(), data: format!("\"{}\"", crate::text::escape_json(text)) } + } + + /// Selectable text with no field-change events (API 1.3). + pub fn readonly_text(text: &str, multiline: bool) -> Self { + Self::Other { + kind: "readonly-text".into(), + data: format!(r#"{{"text":"{}","multiline":{multiline}}}"#, crate::text::escape_json(text)), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Field { pub key: String, @@ -273,6 +288,21 @@ pub enum ViewEvent { } impl ViewEvent { + /// The absolute zero-based row selected in a table (API 1.3). + pub fn table_select(&self) -> Option { + self.table_row("table-select") + } + + /// The absolute zero-based row activated in a table (API 1.3). + pub fn table_activate(&self) -> Option { + self.table_row("table-activate") + } + + fn table_row(&self, expected: &str) -> Option { + let Self::Other { kind, data } = self else { return None }; + (kind == expected).then(|| json_number(data, "row")).flatten() + } + /// 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 @@ -342,6 +372,27 @@ pub trait Views { mod tests { use super::*; + #[test] + fn readonly_fields_escape_text_without_changing_released_variants() { + assert_eq!(FieldValue::label("a\"\n"), FieldValue::Other { kind: "label".into(), data: r#""a\"\n""#.into() }); + assert_eq!( + FieldValue::readonly_text("a\nβ", true), + FieldValue::Other { kind: "readonly-text".into(), data: r#"{"text":"a\nβ","multiline":true}"#.into() } + ); + } + + #[test] + fn table_row_events_only_decode_their_own_valid_payloads() { + let event = |kind: &str, data: &str| ViewEvent::Other { kind: kind.into(), data: data.into() }; + assert_eq!(event("table-select", r#"{"row":51}"#).table_select(), Some(51)); + assert_eq!(event("table-activate", r#"{"row":0}"#).table_activate(), Some(0)); + for data in ["{}", r#"{"row":-1}"#, r#"{"row":4294967296}"#, r#"{"row":"1"}"#] { + assert_eq!(event("table-select", data).table_select(), None); + } + assert_eq!(event("select", r#"{"row":1}"#).table_select(), None); + assert_eq!(event("table-select", r#"{"row":1}"#).table_activate(), None); + } + #[test] fn a_sortable_table_becomes_the_other_table_body() { let table = Table { diff --git a/src/bindings.ts b/src/bindings.ts index a763377ba..beb15c9eb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3792,7 +3792,10 @@ export type FacetConfig = { webRoot?: string, }; -export type FieldValue = { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: { +export type FieldValue = { kind: "label"; value: string } | { kind: "readonlyText"; value: { + text: string, + multiline: boolean, +} } | { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: { options: string[], selected: string, } }; @@ -8127,7 +8130,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 } | { kind: "sort"; column: number; descending: boolean }; +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 } | { kind: "tableSelect"; row: number } | { kind: "tableActivate"; row: number }; export type ViewField = { key: string, diff --git a/src/features/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index e6a8a2247..8f008fe1b 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ContextMenuHost, hideContextMenu } from "@/ui"; import type { View, ViewEvent } from "@/bindings"; @@ -419,6 +419,7 @@ describe("PluginView", () => { render(); await user.dblClick(await screen.findByText("London")); const input = screen.getByLabelText("Cell value"); + expect(events.some((e) => e.kind === "tableActivate")).toBe(false); await user.clear(input); await user.type(input, "Paris{Enter}"); await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" })); @@ -533,3 +534,182 @@ describe("PluginView", () => { expect(await screen.findByText("boom")).toBeInTheDocument(); }); }); + +describe("API 1.3 read-only values and table rows", () => { + it("shows selectable labels and readonly text without sending field changes", async () => { + const events: ViewEvent[] = []; + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "form", + value: [ + { key: "title", label: "Title", value: { kind: "label", value: "Merge request" } }, + { + key: "author", + label: "Author", + value: { kind: "readonlyText", value: { text: "Ada", multiline: false } }, + }, + { + key: "body", + label: "Description", + value: { kind: "readonlyText", value: { text: "one\ntwo", multiline: true } }, + }, + ], + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render(); + expect(await screen.findByLabelText("Title")).toHaveTextContent("Merge request"); + for (const label of ["Author", "Description"]) { + const input = screen.getByLabelText(label); + expect(input).toHaveAttribute("readonly"); + await user.type(input, "changed"); + await user.tab(); + } + expect(screen.getByLabelText("Author")).toHaveValue("Ada"); + expect(screen.getByLabelText("Description")).toHaveValue("one\ntwo"); + expect(events).toEqual([]); + }); + + it("keeps editable table row actions beside contributed commands", async () => { + const events: ViewEvent[] = []; + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Value"], + rows: [["Editable"]], + totalRows: 1, + firstRow: 0, + editable: true, + sortable: false, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + [{ label: "Inspect editable", run: () => {} }]} /> + , + ); + const cell = await screen.findByText("Editable"); + await user.pointer({ target: cell, keys: "[MouseRight]" }); + expect(screen.getByText("Inspect editable")).toBeInTheDocument(); + expect(screen.getByText("Insert Row Above")).toBeInTheDocument(); + await user.click(screen.getByText("Delete Row")); + expect(events).toContainEqual({ kind: "deleteRows", rows: [0] }); + hideContextMenu(); + }); + + it("uses absolute row indices after paging and supports keyboard context menus", async () => { + const events: ViewEvent[] = []; + const itemMenu = vi.fn(() => [{ label: "Inspect paged row", run: () => {} }]); + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Route"], + rows: [["/paged"]], + totalRows: 100, + firstRow: 50, + editable: false, + sortable: true, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + + , + ); + const grid = await screen.findByRole("grid"); + fireEvent.scroll(grid, { target: { scrollTop: 50 * 24 } }); + const cell = await screen.findByText("/paged"); + await user.click(cell); + expect(events).toContainEqual({ kind: "tableSelect", row: 50 }); + (cell.closest('[role="row"]') as HTMLElement).focus(); + await user.keyboard("{Enter}{Shift>}{F10}{/Shift}"); + expect(events).toContainEqual({ kind: "tableActivate", row: 50 }); + expect(itemMenu).toHaveBeenCalledWith("50"); + expect(screen.getByText("Inspect paged row")).toBeInTheDocument(); + hideContextMenu(); + }); + + it.each([false, true])( + "sends absolute row events and contributed context commands (sortable=%s)", + async (sortable) => { + const events: ViewEvent[] = []; + const menuRun = vi.fn(); + const itemMenu = vi.fn((key: string) => [{ label: `Inspect ${key}`, run: menuRun }]); + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Route"], + rows: [["/users"], ["/posts"]], + totalRows: 2, + firstRow: 0, + editable: false, + sortable, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + + , + ); + const cell = await screen.findByText("/posts"); + await user.click(cell); + expect(events).toContainEqual({ kind: "tableSelect", row: 1 }); + expect(cell.closest('[role="row"]')).toHaveAttribute("aria-selected", "true"); + await user.dblClick(cell); + expect(events).toContainEqual({ kind: "tableActivate", row: 1 }); + const row = cell.closest('[role="row"]') as HTMLElement; + row.focus(); + await user.keyboard("{Enter} "); + expect(events.filter((e) => e.kind === "tableActivate")).toHaveLength(2); + await user.pointer({ target: cell, keys: "[MouseRight]" }); + expect(itemMenu).toHaveBeenCalledWith("1"); + await user.click(screen.getByText("Inspect 1")); + expect(menuRun).toHaveBeenCalledOnce(); + expect(screen.queryByText("Insert Row Above")).not.toBeInTheDocument(); + hideContextMenu(); + }, + ); +}); diff --git a/src/features/plugins/views/PluginView.module.css b/src/features/plugins/views/PluginView.module.css index d53ff8b24..b8f4c443a 100644 --- a/src/features/plugins/views/PluginView.module.css +++ b/src/features/plugins/views/PluginView.module.css @@ -226,3 +226,22 @@ width: var(--h-floating-toolbar-button); height: var(--h-floating-toolbar-button); } + +.readonlyValue { + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} + +/* Selectable, but framed like a disabled field so it doesn't read as editable. */ +.readonlyField:read-only { + border: 0; + border-radius: var(--radius-control, 4px); + background: transparent; + box-shadow: inset 0 0 0 1px var(--border); +} + +.tableRow:focus-visible { + outline: 1px solid var(--focus-ring); + outline-offset: -1px; +} diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index b92909f5b..669b2cb36 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -16,6 +16,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; +import clsx from "clsx"; import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings"; import { tx, VerbatimTextContext } from "@/platform/i18n"; import { @@ -46,7 +47,7 @@ export interface PluginViewProps { revision?: number; /** Called after an event was handled (custom editors re-render on the document instead). */ label?: string; - /** Context menu entries of a tree or list item, by item key. */ + /** Context menu entries by item key (tables use the absolute row index). */ itemMenu?: (key: string) => MenuItem[]; } @@ -203,7 +204,7 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps ); case "table": - return ; + return ; case "html": return ; } @@ -283,6 +284,17 @@ function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => {field.label} )} + {v.kind === "label" && ( + + {v.value} + + )} + {v.kind === "readonlyText" && + (v.value.multiline ? ( +