From dc69c36604fc2d8e0c2fa0e8b7b2afb1ec0c505f Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:36:59 +0100 Subject: [PATCH] feat(plugins): read-only form fields and table row interactions (#2013) API 1.3 through the released `other` extension cases, no WIT change: form fields `label` (plain value) and `readonly-text` (selectable, optionally multi-line, drawn as read-only), and table view-events `table-select` / `table-activate` (click, Space; double-click, Enter) with absolute row indices. Table rows also get the view's `viewItem.context` commands, keyed by row. SDK: `FieldValue::label`, `FieldValue::readonly_text`, `ViewEvent::table_select` / `table_activate`. Closes #1953. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/tests/plugins.rs | 24 +++ crates/ide-plugin/src/runtime.rs | 63 +++++- crates/ide-plugin/src/types.rs | 4 + .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../endpoints/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/hello/dist/plugin.wasm | Bin 143251 -> 143251 bytes .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/dist/plugin.wasm | Bin 150169 -> 150169 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../misbehave/dist/plugin.wasm.sources | 2 +- .../path-events/dist/plugin.wasm.sources | 2 +- .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/tables/dist/plugin.wasm | Bin 115788 -> 119171 bytes .../fixtures/tables/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/tables/plugin.toml | 19 +- .../tests/fixtures/tables/src/lib.rs | 48 ++++- plugins/README.md | 20 ++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- plugins/official/csv-editor/dist/plugin.wasm | Bin 133421 -> 133421 bytes .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../official/regex-tester/dist/plugin.wasm | Bin 931754 -> 931754 bytes .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/views.rs | 51 +++++ src/bindings.ts | 7 +- src/features/plugins/__tests__/views.test.tsx | 182 +++++++++++++++++- .../plugins/views/PluginView.module.css | 19 ++ src/features/plugins/views/PluginView.tsx | 60 +++++- 39 files changed, 502 insertions(+), 41 deletions(-) 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 37482b7f6909734c0c802dbd345841c60515e8aa..b01c003f5959c29aebc7e55bf4950cd35e20a7e1 100644 GIT binary patch delta 58 zcmV-A0LA~4-w2c62(T#_0|EgEvn&|hF9Fe$3P@oA(z9$x`;C)xFD8@y)-aQ2)7 Pjn*)OE!Vd#*8yf&l$RJH 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 bf157c0429e6f47b47f8433311f850d452bc4aa9..ec949d20b1971fd4c7e71d2d0e94909df1363148 100644 GIT binary patch delta 64 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 Yy&?D*_kRTp?caFr46NHUn`SinfAN;1z5oCK delta 19680 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, + 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 7b5db1cf8af1e5f88249d052185d15142366415e..46f165e97e390a9de54bd6832f98e5993964dad4 100644 GIT binary patch delta 92 zcmV-i0Hgn{k_fGm2(VKW1_A*H0}TVSSrkGG0k^Z9AB6z{vy&nrUIDbTfg!V*0k*R> yyYUQ@`F19gyvQ(0%D4W?0WUtT7bW2U 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 3c9407408c32ac0a72a08bce4e687e25a8acd7ba..81d96c8576543aef0148071d959d7e528681de0a 100644 GIT binary patch delta 117 zcmZ4W-eT2ziwzGYnVFfmHb0U4DaN>c@;!q<#vRQDhV2H1j6lo;#LPg<0>rF9%(mUY zkbSbv^iwI!YSSb0*bSy1&SiIE+%}ypk6m~B25Uxj#`X>Q>_E%`#GF9P1;pGy%(Hz% IKJTyN0IFju?EnA( delta 112 zcmZ4W-eT2ziwzGYnVGnlH$Rd5DaN>M@;!q<#_i1phV2H1j6lo;#LPg<0>rF9%(mUY zkbSbvbjdt+C&nGqWAfN_rytH`58J-Mno*sxeM3Gw5OV-AClGT1F*gwNY~PU2`|CIW D+~p}d 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 ? ( +