diff --git a/src/editor.rs b/src/editor.rs index a200ffb..c2c27f4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -50,6 +50,7 @@ use crossterm::{ }, terminal, ExecutableCommand, }; +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; #[cfg(unix)] use nix::sys::signal::{self, Signal}; #[cfg(unix)] @@ -194,20 +195,6 @@ async fn apply_plugin_manager_operation( restart_plugins: installed.has_husk || installed.has_companion, }) } - "trust" => { - let installed = manager - .installed(&id)? - .ok_or_else(|| anyhow::anyhow!("plugin `{id}` is not installed"))?; - manager.trust_package_grammars(&id)?; - Ok(PluginManagerOperationOutcome { - message: format!( - "Approved the current native grammars for {}.", - installed.name - ), - reload_languages: installed.has_languages, - restart_plugins: false, - }) - } "remove" => { let installed = manager .installed(&id)? @@ -232,21 +219,10 @@ fn plugin_manager_items( .iter() .map(|package| (&package.id, package)) .collect::>(); - let mut items = vec![PickerItem { - id: "custom-source".to_string(), - icon: None, - label: "Install from GitHub or local path…".to_string(), - kind: Some("Custom source".to_string()), - annotation: None, - detail: Some("Enter owner/repo[@ref] or a development checkout path".to_string()), - data: Value::Null, - matches: Vec::new(), - detail_matches: Vec::new(), - preview: Some(PickerPreview::Text { - text: "Custom sources are not reviewed by the Red catalog. Red still validates the package and requires separate approval before loading a native grammar.".to_string(), - language: None, - }), - }]; + let mut installed_catalog_items = Vec::new(); + let mut installed_custom_items = Vec::new(); + let mut available_items = Vec::new(); + let mut unavailable_items = Vec::new(); for package in catalog.values() { let installed_at_id = installed_by_id.get(&package.id).copied(); @@ -257,58 +233,48 @@ fn plugin_manager_items( let requirements = package .requirements .iter() - .map(|requirement| { - let state = if command_available(&requirement.command) { - "available" - } else { - "not found on PATH" - }; - let importance = if requirement.optional { - "optional" - } else { - "required" - }; - format!( - "{} — {} ({importance}; {state})", - requirement.command, requirement.purpose - ) - }) + .flat_map(|requirement| [&requirement.command, &requirement.purpose]) + .cloned() .collect::>(); - let artifact = package.artifact(crate::language::host_target()); - let native_grammars = artifact - .map(|artifact| artifact.grammars.len()) - .unwrap_or_default(); - let annotation = match (installed, custom_install) { - (Some(installed), _) if installed.version < package.version => { - format!("{} → {} available", installed.version, package.version) - } - (Some(installed), _) => format!("{} installed", installed.version), - (None, Some(custom)) => { + let (lifecycle, compact_annotation) = match (installed, custom_install) { + (Some(installed), _) if installed.version < package.version => ( format!( - "{} available; custom {} installed", - package.version, custom.version - ) - } - (None, None) => package.version.to_string(), + "{} · v{} → v{}", + package.tier.label(), + installed.version, + package.version + ), + format!("v{} → v{}", installed.version, package.version), + ), + (Some(installed), _) => ( + format!( + "{} · v{} · Installed", + package.tier.label(), + installed.version + ), + format!("v{}", installed.version), + ), + (None, Some(custom)) => ( + format!( + "{} · v{} · Custom v{} installed", + package.tier.label(), + package.version, + custom.version + ), + format!("v{}", package.version), + ), + (None, None) => ( + format!("{} · v{}", package.tier.label(), package.version), + format!("v{}", package.version), + ), }; let (availability, unavailable_message) = catalog_package_availability(package); - let mut preview = format!( - "{}\n\nLanguages: {}\nPublisher: {}\nReview tier: {}", - package.description, - package.languages.join(", "), - package.repository, - package.tier.label() - ); - if native_grammars > 0 { - preview.push_str(&format!( - "\n\nNative grammars: {native_grammars}. Installing does not approve them automatically." - )); - } - if !requirements.is_empty() { - preview.push_str("\n\nExternal requirements:\n- "); - preview.push_str(&requirements.join("\n- ")); - } - items.push(PickerItem { + let (annotation, compact_annotation) = unavailable_message + .as_ref() + .map_or((lifecycle, compact_annotation), |_| { + (availability.clone(), availability) + }); + let item = PickerItem { id: installed.map_or_else( || { let prefix = if unavailable_message.is_some() { @@ -322,17 +288,39 @@ fn plugin_manager_items( ), icon: None, label: package.name.clone(), - kind: Some(availability), + kind: Some(if unavailable_message.is_some() { + "Warning".to_string() + } else { + "Language".to_string() + }), annotation: Some(annotation), - detail: Some(package.description.clone()), - data: json!({ "package": package.id.as_str() }), + detail: None, + data: json!({ + "package": package.id.as_str(), + "search_terms": [ + package.id.as_str(), + package.description.as_str(), + package.languages.join(" "), + package.repository.as_str(), + package.source_path.to_string_lossy(), + requirements.join(" "), + package.tier.label(), + ], + "annotation_align": "right", + "annotation_right_margin": 2, + "compact_annotation": compact_annotation, + }), matches: Vec::new(), detail_matches: Vec::new(), - preview: Some(PickerPreview::Text { - text: preview, - language: None, - }), - }); + preview: None, + }; + if installed.is_some() { + installed_catalog_items.push(item); + } else if unavailable_message.is_some() { + unavailable_items.push(item); + } else { + available_items.push(item); + } } for package in installed { @@ -341,30 +329,260 @@ fn plugin_manager_items( { continue; } - items.push(PickerItem { + let catalog_source = matches!( + &package.source, + plugin::package::PluginInstallSource::Catalog { + catalog_url: source_catalog_url, + .. + } if source_catalog_url == catalog_url + ); + installed_custom_items.push(PickerItem { id: format!("installed:{}", package.id), icon: None, label: package.name.clone(), - kind: Some("Custom source".to_string()), + kind: Some("Language".to_string()), annotation: Some(format!( - "{} {}", + "{} · v{} · {}", + if catalog_source { + "Installed" + } else { + "Custom" + }, package.version, if package.enabled { - "enabled" + "Enabled" } else { - "disabled" + "Disabled" } )), - detail: Some(format!("Languages: {}", package.languages.join(", "))), - data: json!({ "package": package.id.as_str() }), + detail: None, + data: json!({ + "package": package.id.as_str(), + "search_terms": [ + package.id.as_str(), + package.languages.join(" "), + if catalog_source { "catalog official" } else { "custom source" }, + ], + "annotation_align": "right", + "annotation_right_margin": 2, + "compact_annotation": format!("v{}", package.version), + }), matches: Vec::new(), detail_matches: Vec::new(), preview: None, }); } + + let mut items = Vec::new(); + items.extend(installed_catalog_items); + items.extend(installed_custom_items); + items.extend(available_items); + items.extend(unavailable_items); + items.push(PickerItem { + id: "custom-source".to_string(), + icon: None, + label: "+ Add custom source…".to_string(), + kind: Some("Add".to_string()), + annotation: None, + detail: None, + data: json!({ + "search_terms": [ + "custom source github repository owner repo ref local path development checkout", + ], + }), + matches: Vec::new(), + detail_matches: Vec::new(), + preview: None, + }); items } +fn plugin_manager_filter_score(item: &PickerItem, query: &str) -> Option { + let matcher = SkimMatcherV2::default(); + query.split_whitespace().try_fold(0, |total, token| { + let label_score = plugin_manager_field_score(&matcher, &item.label, token, 5_000); + let annotation_score = item + .annotation + .as_deref() + .and_then(|value| plugin_manager_field_score(&matcher, value, token, 4_000)); + let metadata_score = item + .data + .get("search_terms") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter_map(|value| plugin_manager_field_score(&matcher, value, token, 3_000)) + .max(); + Some( + total + + label_score + .into_iter() + .chain(annotation_score) + .chain(metadata_score) + .max()?, + ) + }) +} + +fn plugin_manager_field_score( + matcher: &SkimMatcherV2, + field: &str, + token: &str, + weight: i64, +) -> Option { + let field_lower = field.to_ascii_lowercase(); + let token_lower = token.to_ascii_lowercase(); + let exact_bonus = if field.eq_ignore_ascii_case(token) { + 2_000 + } else if field_lower.starts_with(&token_lower) { + 1_000 + } else if field_lower.contains(&token_lower) { + 500 + } else { + 0 + }; + matcher + .fuzzy_match(field, token) + .map(|score| weight + exact_bonus + score) +} + +fn language_pack_picker( + editor: &Editor, + items: Vec, + status: impl Into, + busy: bool, +) -> Picker { + Picker::builder() + .title("Language packs") + .structured_items(items) + .id(PLUGIN_MANAGER_PICKER_ID) + .placeholder("Search language packs") + .status(status) + .busy(busy) + .filter_action(plugin_manager_filter_score) + .content_sized(88, 14) + .fit_content_width(56) + .status_on_query_line() + .select_action(Action::PluginManagerSelect) + .build(editor) +} + +fn catalog_package_action_status(package: &plugin::catalog::CatalogPackage) -> String { + let missing = package + .requirements + .iter() + .filter(|requirement| !command_available(&requirement.command)) + .map(|requirement| { + format!( + "{} {}", + requirement.command, + if requirement.optional { + "optional" + } else { + "required" + } + ) + }) + .collect::>(); + let readiness = if missing.is_empty() { + "Tools ready".to_string() + } else { + format!("Missing {}", missing.join(", ")) + }; + format!( + "{} · v{} · {} · {readiness}", + package.tier.label(), + package.version, + package.languages.join(", ") + ) +} + +fn plugin_manager_action_item( + id: String, + label: impl Into, + annotation: impl Into, + kind: &str, +) -> PickerItem { + PickerItem { + id, + icon: None, + label: label.into(), + kind: Some(kind.to_string()), + annotation: Some(annotation.into()), + detail: None, + data: Value::Null, + matches: Vec::new(), + detail_matches: Vec::new(), + preview: None, + } +} + +fn native_grammar_consent_message( + package: &plugin::catalog::CatalogPackage, +) -> anyhow::Result { + let artifact = package + .artifact(crate::language::host_target()) + .ok_or_else(|| { + anyhow::anyhow!( + "language pack `{}` is unavailable for `{}`", + package.id, + crate::language::host_target() + ) + })?; + let digests = artifact + .grammars + .iter() + .map(|(language, digest)| format!("{language}: {digest}")) + .collect::>() + .join("\n"); + Ok(format!( + "Native Tree-sitter grammars execute inside Red.\n\nPackage: {} v{}\nSource: {} @ {}\n\nVerified SHA-256:\n{}\n\nApproval is limited to these exact catalog-verified bytes.", + package.name, + package.version, + package.repository, + package.resolved_commit, + digests + )) +} + +fn installed_grammar_consent_message( + package: &plugin::package::InstalledPlugin, + digests: &BTreeMap, +) -> anyhow::Result { + anyhow::ensure!( + !digests.is_empty(), + "language pack `{}` has no native grammars to approve", + package.id + ); + let source = match &package.source { + plugin::package::PluginInstallSource::Path { path } => { + format!("Local path: {}", path.display()) + } + plugin::package::PluginInstallSource::GitHub { + repository, + requested_version, + } => format!( + "GitHub: {repository}@{}", + requested_version.as_deref().unwrap_or("default branch") + ), + plugin::package::PluginInstallSource::Catalog { + catalog_url, + resolved_commit, + .. + } => format!("Catalog: {catalog_url}\nResolved commit: {resolved_commit}"), + }; + let digests = digests + .iter() + .map(|(language, digest)| format!("{language}: {digest}")) + .collect::>() + .join("\n"); + Ok(format!( + "Native Tree-sitter grammars execute inside Red.\n\nPackage: {} v{}\n{}\n\nCurrent SHA-256:\n{}\n\nApproval is limited to these exact installed bytes. If they change before approval, Red will stop.", + package.name, package.version, source, digests + )) +} + fn catalog_package_availability( package: &plugin::catalog::CatalogPackage, ) -> (String, Option) { @@ -1811,6 +2029,15 @@ pub enum Action { packages: Vec, error: Option, }, + PluginManagerCatalogConsent { + catalog_url: String, + package: Box, + }, + PluginManagerTrustConsent(String), + PluginManagerTrustConfirmed { + package: String, + digests: BTreeMap, + }, PluginManagerCatalogInstall { catalog_url: String, package: Box, @@ -15080,15 +15307,7 @@ impl Editor { &self.plugin_catalog, &self.plugin_catalog_url, ); - let picker = Picker::builder() - .title("Language packs") - .structured_items(items) - .id(PLUGIN_MANAGER_PICKER_ID) - .placeholder("Filter language packs") - .status("Refreshing the curated catalog…") - .busy(true) - .select_action(Action::PluginManagerSelect) - .build(self); + let picker = language_pack_picker(self, items, "Loading official catalog…", true); self.current_dialog = Some(Box::new(picker)); let catalog_url = plugin::catalog::catalog_url(); tokio::spawn(async move { @@ -15123,18 +15342,55 @@ impl Editor { } let manager = plugin::package::PluginPackageManager::new(Config::config_dir()); let installed = manager.list().unwrap_or_default(); - let items = plugin_manager_items( + let mut items = plugin_manager_items( &installed, &self.plugin_catalog, &self.plugin_catalog_url, ); + if error.is_some() { + items.insert( + 0, + plugin_manager_action_item( + "retry-catalog".to_string(), + "Retry official catalog", + "The last refresh failed", + "Retry", + ), + ); + } + let initial_catalog_selection = if error.is_some() { + Some("retry-catalog".to_string()) + } else if installed.is_empty() { + items + .iter() + .find(|item| item.id.starts_with("catalog:")) + .map(|item| item.id.clone()) + } else { + None + }; if let Some(dialog) = &mut self.current_dialog { dialog.update_picker(PLUGIN_MANAGER_PICKER_ID, PickerUpdate::Items(items)); + if let Some(selection) = initial_catalog_selection { + dialog.update_picker( + PLUGIN_MANAGER_PICKER_ID, + PickerUpdate::Selection(selection), + ); + } dialog.update_picker( PLUGIN_MANAGER_PICKER_ID, PickerUpdate::Status(Some(error.as_ref().map_or_else( - || format!("{} curated pack(s)", self.plugin_catalog.len()), - |error| format!("Catalog unavailable: {error}"), + || { + format!( + "{} pack{} · Enter open", + self.plugin_catalog.len(), + if self.plugin_catalog.len() == 1 { + "" + } else { + "s" + } + ) + }, + |error| format!("Catalog unavailable: {error} · Enter to retry"), ))), ); dialog.update_picker(PLUGIN_MANAGER_PICKER_ID, PickerUpdate::Busy(false)); @@ -15143,7 +15399,10 @@ impl Editor { } Action::PluginManagerSelect(selection) => { add_to_history = false; - if selection == "custom-source" { + if selection == "retry-catalog" { + self.last_error = Some("Retrying official language-pack catalog…".to_string()); + ACTION_DISPATCHER.send_request(PluginRequest::Action(Action::ListPlugins)); + } else if selection == "custom-source" { self.current_dialog = Some(Box::new(InputPrompt::new( self, "GitHub owner/repo[@ref] or local path", @@ -15169,15 +15428,7 @@ impl Editor { &self.plugin_catalog, &self.plugin_catalog_url, ); - let picker = Picker::builder() - .title("Language packs") - .structured_items(items) - .id(PLUGIN_MANAGER_PICKER_ID) - .placeholder("Filter language packs") - .status(message) - .busy(false) - .select_action(Action::PluginManagerSelect) - .build(self); + let picker = language_pack_picker(self, items, message, false); self.current_dialog = Some(Box::new(picker)); } else if let Some(raw_id) = selection.strip_prefix("installed:") { let id = plugin::package::PluginId::parse(raw_id)?; @@ -15190,17 +15441,70 @@ impl Editor { } else { "enable" }; - let mut actions = vec![format!("{toggle}\t{id}"), format!("update\t{id}")]; + let mut actions = vec![plugin_manager_action_item( + format!("update\t{id}"), + "Check for updates", + format!("Installed v{}", installed.version), + "Update", + )]; + actions.push(plugin_manager_action_item( + format!("{toggle}\t{id}"), + if installed.enabled { + "Disable language pack" + } else { + "Enable language pack" + }, + if installed.enabled { + "Preserves files and saved data" + } else { + "Restore language support" + }, + if installed.enabled { + "Disable" + } else { + "Enable" + }, + )); if installed.has_native_grammars { - actions.push(format!("trust\t{id}")); + actions.push(plugin_manager_action_item( + format!("trust\t{id}"), + "Approve native grammar", + "Trust the currently installed grammar bytes", + "Warning", + )); } - actions.push(format!("remove\t{id}")); - self.current_dialog = Some(Box::new(Picker::new( - Some(format!("Manage {id}")), - self, - &actions, - Some(PLUGIN_MANAGER_ACTION_PICKER_ID), - ))); + actions.push(plugin_manager_action_item( + format!("remove\t{id}"), + "Remove language pack", + "Saved data is preserved", + "Delete", + )); + let status = format!( + "v{} · {} · {}", + installed.version, + if installed.enabled { + "Enabled" + } else { + "Disabled" + }, + installed.languages.join(", ") + ); + let picker = Picker::builder() + .title(&installed.name) + .structured_items(actions) + .id(PLUGIN_MANAGER_ACTION_PICKER_ID) + .placeholder("Filter actions") + .status(status) + .content_sized(84, 8) + .select_action(|operation| { + if let Some(package) = operation.strip_prefix("trust\t") { + Action::PluginManagerTrustConsent(package.to_string()) + } else { + Action::PluginManagerAction(operation) + } + }) + .build(self); + self.current_dialog = Some(Box::new(picker)); } else if let Some(raw_id) = selection.strip_prefix("catalog:") { let id = plugin::package::PluginId::parse(raw_id)?; let manager = plugin::package::PluginPackageManager::new(Config::config_dir()); @@ -15225,85 +15529,54 @@ impl Editor { ) })?; let has_native_grammars = !artifact.grammars.is_empty(); - let mut install_details = Vec::new(); - if replacing_custom { - install_details - .push("Replaces the custom package currently installed under this ID"); - } + let mut choices = Vec::new(); if has_native_grammars { - install_details.push( - "Language metadata is installed, but native highlighting remains disabled", - ); + choices.push(plugin_manager_action_item( + "install-and-trust".to_string(), + "Install with syntax highlighting", + format!("Approve {} verified grammar(s)", artifact.grammars.len()), + "Warning", + )); } - let mut choices = vec![PickerItem { - id: "cancel".to_string(), - icon: None, - label: "Cancel".to_string(), - kind: Some("Safe default".to_string()), - annotation: None, - detail: Some("Leave the package unchanged".to_string()), - data: Value::Null, - matches: Vec::new(), - detail_matches: Vec::new(), - preview: None, - }]; - choices.push(PickerItem { - id: "install".to_string(), - icon: None, - label: if has_native_grammars { - "Install without approving native grammars".to_string() + choices.push(plugin_manager_action_item( + "install".to_string(), + if has_native_grammars { + "Install without native highlighting" } else { - "Install language pack".to_string() + "Install language pack" }, - kind: Some("Install".to_string()), - annotation: Some(package.version.to_string()), - detail: (!install_details.is_empty()).then(|| install_details.join(". ")), - data: Value::Null, - matches: Vec::new(), - detail_matches: Vec::new(), - preview: None, - }); - if has_native_grammars { - let digests = artifact - .grammars - .iter() - .map(|(language, digest)| format!("{language}: {digest}")) - .collect::>() - .join("\n"); - choices.push(PickerItem { - id: "install-and-trust".to_string(), - icon: None, - label: "Install and approve native grammars".to_string(), - kind: Some("Runs native code".to_string()), - annotation: Some(format!("{} verified grammar(s)", artifact.grammars.len())), - detail: Some( - "Approve these exact catalog-verified bytes for in-process syntax highlighting" - .to_string(), - ), - data: Value::Null, - matches: Vec::new(), - detail_matches: Vec::new(), - preview: Some(PickerPreview::Text { - text: format!( - "Native Tree-sitter grammars execute inside Red. Approval is bound to these exact SHA-256 digests and is invalidated when they change:\n\n{digests}" - ), - language: None, - }), - }); - } + if has_native_grammars { + "Native grammar remains disabled" + } else { + "No native grammar approval required" + }, + "Install", + )); + choices.push(plugin_manager_action_item( + "cancel".to_string(), + "Back to language packs", + "Leave the package unchanged", + "Cancel", + )); let confirmed_package = package.clone(); let confirmed_catalog_url = self.plugin_catalog_url.clone(); + let mut status = catalog_package_action_status(package); + if replacing_custom { + status.push_str(" · Replaces custom install"); + } let picker = Picker::builder() .title(&format!("Install {}", package.name)) .structured_items(choices) .id(PLUGIN_MANAGER_INSTALL_PICKER_ID) + .placeholder("Filter install options") + .status(status) + .content_sized(84, 8) .select_action(move |choice| match choice.as_str() { - "cancel" => Action::Refresh, - "install-and-trust" => confirmed_catalog_install_action( - &confirmed_catalog_url, - &confirmed_package, - true, - ), + "cancel" => Action::ListPlugins, + "install-and-trust" => Action::PluginManagerCatalogConsent { + catalog_url: confirmed_catalog_url.clone(), + package: Box::new(confirmed_package.clone()), + }, _ => confirmed_catalog_install_action( &confirmed_catalog_url, &confirmed_package, @@ -15315,6 +15588,93 @@ impl Editor { } self.render(buffer)?; } + Action::PluginManagerCatalogConsent { + catalog_url, + package, + } => { + add_to_history = false; + let message = native_grammar_consent_message(package)?; + let accept = confirmed_catalog_install_action(catalog_url, package, true); + let cancel = Action::PluginManagerSelect(format!("catalog:{}", package.id)); + self.current_dialog = Some(Box::new(Confirmation::new_actions( + self, + format!("Approve native grammars for {}", package.name), + message, + "Approve and install", + "Back", + accept, + cancel, + ))); + self.render(buffer)?; + } + Action::PluginManagerTrustConsent(raw_id) => { + add_to_history = false; + let id = plugin::package::PluginId::parse(raw_id)?; + let manager = plugin::package::PluginPackageManager::new(Config::config_dir()); + let installed = manager + .installed(&id)? + .ok_or_else(|| anyhow::anyhow!("plugin `{id}` is no longer installed"))?; + let digests = manager.package_grammar_digests(&id)?; + let message = installed_grammar_consent_message(&installed, &digests)?; + let accept = Action::PluginManagerTrustConfirmed { + package: id.to_string(), + digests, + }; + let cancel = Action::PluginManagerSelect(format!("installed:{id}")); + self.current_dialog = Some(Box::new(Confirmation::new_actions( + self, + format!("Approve native grammars for {}", installed.name), + message, + "Approve exact bytes", + "Back", + accept, + cancel, + ))); + self.render(buffer)?; + } + Action::PluginManagerTrustConfirmed { package, digests } => { + add_to_history = false; + let package = package.clone(); + let digests = digests.clone(); + self.last_error = Some("Approving native grammar bytes…".to_string()); + tokio::spawn(async move { + let manager = plugin::package::PluginPackageManager::new(Config::config_dir()); + let result = (|| -> anyhow::Result { + let id = plugin::package::PluginId::parse(&package)?; + let installed = manager + .installed(&id)? + .ok_or_else(|| anyhow::anyhow!("plugin `{id}` is not installed"))?; + manager.trust_package_grammars_exact(&id, &digests)?; + Ok(PluginManagerOperationOutcome { + message: format!( + "Approved the confirmed native grammars for {}.", + installed.name + ), + reload_languages: installed.has_languages, + restart_plugins: false, + }) + })(); + let (message, reload_languages, restart_plugins) = match result { + Ok(outcome) => ( + outcome.message, + outcome.reload_languages, + outcome.restart_plugins, + ), + Err(error) => ( + format!("Native grammar approval failed: {error}"), + false, + false, + ), + }; + ACTION_DISPATCHER.send_request(PluginRequest::Action( + Action::PluginManagerFinished { + message, + reload_languages, + restart_plugins, + }, + )); + }); + } Action::PluginManagerCatalogInstall { catalog_url, package, @@ -15444,7 +15804,17 @@ impl Editor { if *restart_plugins { message.push_str(" Restart Red to refresh plugin code."); } - self.last_error = Some(message); + self.last_error = Some(message.clone()); + let manager = plugin::package::PluginPackageManager::new(Config::config_dir()); + let installed = manager.list().unwrap_or_default(); + let items = plugin_manager_items( + &installed, + &self.plugin_catalog, + &self.plugin_catalog_url, + ); + self.current_dialog = + Some(Box::new(language_pack_picker(self, items, message, false))); + self.render(buffer)?; } Action::Command(cmd) => { log!("Handling command: {cmd}"); @@ -22755,15 +23125,15 @@ mod test { let items = plugin_manager_items(&[], &catalog, plugin::catalog::DEFAULT_PLUGIN_CATALOG_URL); - assert_eq!(items[0].id, "custom-source"); - assert_eq!(items[0].kind.as_deref(), Some("Custom source")); - assert_eq!(items[1].id, "catalog:go-language"); - assert_eq!(items[1].kind.as_deref(), Some("Official")); - let preview = match items[1].preview.as_ref().unwrap() { - PickerPreview::Text { text, .. } => text, - PickerPreview::Location { .. } => panic!("expected text preview"), - }; - assert!(preview.contains("does not approve them automatically")); + assert_eq!(items[0].id, "catalog:go-language"); + assert_eq!(items[0].label, "Go language support"); + assert!(items[0].annotation.as_deref().unwrap().contains("Official")); + assert_eq!(items[1].id, "custom-source"); + assert_eq!(items[1].label, "+ Add custom source…"); + assert!(items[1].annotation.is_none()); + assert!(items.iter().all(|item| item.preview.is_none())); + assert!(items.iter().all(|item| item.detail.is_none())); + assert!(plugin_manager_filter_score(&items[0], "gopls").is_some()); } #[test] @@ -22775,8 +23145,12 @@ mod test { let items = plugin_manager_items(&[], &catalog, plugin::catalog::DEFAULT_PLUGIN_CATALOG_URL); - assert_eq!(items[1].id, "unavailable:go-language"); - assert_eq!(items[1].kind.as_deref(), Some("Requires Red API >=999.0.0")); + assert_eq!(items[0].id, "unavailable:go-language"); + assert_eq!(items[0].kind.as_deref(), Some("Warning")); + assert_eq!( + items[0].annotation.as_deref(), + Some("Requires Red API >=999.0.0") + ); let (_, message) = catalog_package_availability( &catalog[&plugin::package::PluginId::parse("go-language").unwrap()], ); @@ -22792,9 +23166,9 @@ mod test { let items = plugin_manager_items(&[], &catalog, plugin::catalog::DEFAULT_PLUGIN_CATALOG_URL); - assert_eq!(items[1].id, "unavailable:go-language"); + assert_eq!(items[0].id, "unavailable:go-language"); assert_eq!( - items[1].kind.as_deref(), + items[0].annotation.as_deref(), Some("Unavailable on this platform") ); let (_, message) = catalog_package_availability( @@ -22835,6 +23209,53 @@ mod test { ); } + #[test] + fn native_grammar_consent_shows_the_exact_confirmed_digest_and_source() { + let package = catalog_test_package(); + let digest = package.artifacts[crate::language::host_target()].grammars["go"].clone(); + + let message = native_grammar_consent_message(&package).unwrap(); + + assert!(message.contains(&package.name)); + assert!(message.contains(&package.repository)); + assert!(message.contains(&package.resolved_commit)); + assert!(message.contains(&format!("go: {digest}"))); + assert!(message.contains("exact catalog-verified bytes")); + } + + #[test] + fn installed_grammar_consent_shows_the_current_digest_and_install_source() { + let digest = "d".repeat(64); + let installed = plugin::package::InstalledPlugin { + id: plugin::package::PluginId::parse("go-language").unwrap(), + name: "Custom Go support".to_string(), + version: semver::Version::parse("1.2.3").unwrap(), + enabled: true, + compatible: true, + has_companion: false, + has_husk: false, + has_languages: true, + has_native_grammars: true, + languages: vec!["go".to_string()], + source: plugin::package::PluginInstallSource::GitHub { + repository: "someone/custom-go-pack".to_string(), + requested_version: Some("v1.2.3".to_string()), + }, + package_root: PathBuf::from("/tmp/go-language"), + }; + + let message = installed_grammar_consent_message( + &installed, + &BTreeMap::from([("go".to_string(), digest.clone())]), + ) + .unwrap(); + + assert!(message.contains("Custom Go support v1.2.3")); + assert!(message.contains("GitHub: someone/custom-go-pack@v1.2.3")); + assert!(message.contains(&format!("go: {digest}"))); + assert!(message.contains("exact installed bytes")); + } + #[test] fn language_pack_picker_reports_catalog_updates_for_installed_packages() { let package = catalog_test_package(); @@ -22870,10 +23291,10 @@ mod test { plugin::catalog::DEFAULT_PLUGIN_CATALOG_URL, ); - assert_eq!(items[1].id, "installed:go-language"); + assert_eq!(items[0].id, "installed:go-language"); assert_eq!( - items[1].annotation.as_deref(), - Some("0.1.0 → 0.2.0 available") + items[0].annotation.as_deref(), + Some("Official · v0.1.0 → v0.2.0") ); } @@ -22906,15 +23327,16 @@ mod test { plugin::catalog::DEFAULT_PLUGIN_CATALOG_URL, ); + assert_eq!(items[0].id, "installed:go-language"); + assert_eq!(items[0].label, "Locally modified Go support"); + assert!(items[0].annotation.as_deref().unwrap().contains("Custom")); assert_eq!(items[1].id, "catalog:go-language"); - assert_eq!(items[1].kind.as_deref(), Some("Official")); assert!(items[1] .annotation .as_deref() .unwrap() - .contains("custom 9.0.0 installed")); - assert_eq!(items[2].id, "installed:go-language"); - assert_eq!(items[2].kind.as_deref(), Some("Custom source")); + .contains("Custom v9.0.0 installed")); + assert_eq!(items[2].id, "custom-source"); } fn drain_plugin_requests() { diff --git a/src/language.rs b/src/language.rs index 5352ec7..4f1e5cf 100644 --- a/src/language.rs +++ b/src/language.rs @@ -190,6 +190,37 @@ impl GrammarTrustStore { self.persist(&trust) } + /// Returns the digest Red would bind to an approval without recording it. + pub(crate) fn path_digest(path: &Path) -> Result { + inspect_native_grammar(path).map(|(_, digest)| digest) + } + + /// Approves paths only when every current digest matches the bytes the user reviewed. + pub(crate) fn trust_paths_exact(&self, paths: &[(PathBuf, String)]) -> Result<()> { + if paths.is_empty() { + return Ok(()); + } + let mut inspected = Vec::with_capacity(paths.len()); + for (path, expected_digest) in paths { + let (canonical, actual_digest) = inspect_native_grammar(path)?; + anyhow::ensure!( + actual_digest.eq_ignore_ascii_case(expected_digest), + "native grammar {} changed since confirmation: expected {}, got {actual_digest}", + canonical.display(), + expected_digest + ); + inspected.push((canonical, actual_digest)); + } + + let mut trust = self.load()?; + for (canonical, digest) in inspected { + trust + .grammars + .insert(canonical.to_string_lossy().into_owned(), digest); + } + self.persist(&trust) + } + /// Revokes every digest approval associated with one canonical grammar path. pub fn revoke_path(&self, path: &Path) -> Result<()> { let canonical = path diff --git a/src/plugin/package.rs b/src/plugin/package.rs index e321a40..33dad3f 100644 --- a/src/plugin/package.rs +++ b/src/plugin/package.rs @@ -729,6 +729,55 @@ impl PluginPackageManager { self.approve_package_grammars(&manifest, &installed.package_root) } + /// Returns the current native grammar digests keyed by package language id. + pub fn package_grammar_digests(&self, id: &PluginId) -> Result> { + let installed = self + .installed(id)? + .ok_or_else(|| anyhow::anyhow!("plugin `{id}` is not installed"))?; + let manifest = PluginPackageManifest::load(&installed.package_root)?; + let paths = package_grammar_paths(&manifest, &installed.package_root); + paths + .into_iter() + .map(|(language, path)| { + GrammarTrustStore::path_digest(&path) + .with_context(|| { + format!("failed to inspect native grammar `{language}` for plugin `{id}`") + }) + .map(|digest| (language, digest)) + }) + .collect() + } + + /// Approves only the exact installed grammar set and digests the user confirmed. + pub fn trust_package_grammars_exact( + &self, + id: &PluginId, + expected_digests: &BTreeMap, + ) -> Result<()> { + let installed = self + .installed(id)? + .ok_or_else(|| anyhow::anyhow!("plugin `{id}` is not installed"))?; + let manifest = PluginPackageManifest::load(&installed.package_root)?; + let paths = package_grammar_paths(&manifest, &installed.package_root); + let current_languages = paths.keys().cloned().collect::>(); + let confirmed_languages = expected_digests.keys().cloned().collect::>(); + anyhow::ensure!( + current_languages == confirmed_languages, + "native grammar set for plugin `{id}` changed since confirmation" + ); + let exact_paths = paths + .into_iter() + .map(|(language, path)| { + let digest = expected_digests + .get(&language) + .expect("confirmed grammar language was checked") + .clone(); + (path, digest) + }) + .collect::>(); + GrammarTrustStore::new(&self.config_dir).trust_paths_exact(&exact_paths) + } + /// Updates an installed package from its retained source. pub async fn update(&self, id: &PluginId) -> Result { self.update_with_trust(id, false).await @@ -910,10 +959,8 @@ impl PluginPackageManager { package_root: &Path, ) -> Result<()> { let trust = GrammarTrustStore::new(&self.config_dir); - let paths = manifest - .languages - .iter() - .filter_map(|(id, language)| manifest.grammar_path(package_root, id, language)) + let paths = package_grammar_paths(manifest, package_root) + .into_values() .collect::>(); trust.trust_paths(&paths) } @@ -927,6 +974,27 @@ impl PluginPackageManager { } } +fn package_grammar_paths( + manifest: &PluginPackageManifest, + package_root: &Path, +) -> BTreeMap { + manifest + .languages + .iter() + .filter(|(_, language)| { + language + .grammar + .as_ref() + .is_some_and(|grammar| grammar.builtin.is_none()) + }) + .filter_map(|(id, language)| { + manifest + .grammar_path(package_root, id, language) + .map(|path| (id.clone(), path)) + }) + .collect() +} + async fn validate_husk_package( manifest: &PluginPackageManifest, package_root: &Path, @@ -1638,6 +1706,34 @@ builtin = "rust" .unwrap(); } + fn write_native_language_package(root: &Path, id: &str, grammar: &[u8]) { + let grammar_dir = root.join("grammars"); + fs::create_dir_all(&grammar_dir).unwrap(); + fs::write(grammar_dir.join("buildspec.so"), grammar).unwrap(); + fs::write( + root.join(PLUGIN_MANIFEST_FILE), + format!( + r#" +schema_version = 1 + +[plugin] +id = "{id}" +name = "Native language package" +version = "1.0.0" +red_api = "^{RED_HOST_API_VERSION}" + +[languages.buildspec] +extensions = ["build"] + +[languages.buildspec.grammar] +path = "grammars/buildspec.so" +symbol = "tree_sitter_buildspec" +"# + ), + ) + .unwrap(); + } + fn write_oversized_native_language_package(root: &Path, id: &str, version: &str) { let grammar_dir = root.join("grammars"); fs::create_dir_all(&grammar_dir).unwrap(); @@ -1902,6 +1998,37 @@ symbol = "tree_sitter_buildspec" assert!(manifest.husk_entry(&installed.package_root).is_none()); } + #[tokio::test] + async fn installed_grammar_approval_is_bound_to_the_confirmed_digest() { + let config = tempfile::tempdir().unwrap(); + let package = tempfile::tempdir().unwrap(); + let original = b"original native grammar"; + write_native_language_package(package.path(), "build-languages", original); + let manager = PluginPackageManager::new(config.path()); + let installed = manager.install_path(package.path()).await.unwrap(); + let digests = manager.package_grammar_digests(&installed.id).unwrap(); + + assert_eq!( + digests["buildspec"], + format!("{:x}", Sha256::digest(original)) + ); + + let grammar = package.path().join("grammars/buildspec.so"); + fs::write(&grammar, b"changed after confirmation").unwrap(); + let error = manager + .trust_package_grammars_exact(&installed.id, &digests) + .unwrap_err(); + assert!(error.to_string().contains("changed since confirmation")); + + fs::write(&grammar, original).unwrap(); + manager + .trust_package_grammars_exact(&installed.id, &digests) + .unwrap(); + assert!(GrammarTrustStore::new(config.path()) + .approved_grammar_path(&grammar, false) + .is_ok()); + } + #[test] fn language_only_manifest_declares_native_highlighting_and_lsp() { let manifest: PluginPackageManifest = toml::from_str(&format!( diff --git a/src/ui/confirmation.rs b/src/ui/confirmation.rs index 595ce18..d02e22e 100644 --- a/src/ui/confirmation.rs +++ b/src/ui/confirmation.rs @@ -12,20 +12,31 @@ use crate::{ }; use super::{ + agent_composer::wrap_text, dialog::{BorderStyle, Dialog, SurfaceRole}, Component, PickerItem, }; -const ACCEPT_LABEL: &str = "[ Accept ]"; -const CANCEL_LABEL: &str = "[ Cancel ]"; const BUTTON_GAP: usize = 2; -/// A two-line confirmation surface that defaults to the safe Cancel action. +enum ConfirmationTarget { + Callback(PickerHandle), + Actions { + accept: Box, + cancel: Box, + }, +} + +/// A confirmation surface that defaults to the safe Cancel action. pub struct Confirmation { dialog: Dialog, message: String, accept_selected: bool, - callback_handle: PickerHandle, + target: ConfirmationTarget, + accept_label: String, + cancel_label: String, + multiline: bool, + scroll: usize, style: Style, theme: Theme, } @@ -37,19 +48,73 @@ impl Confirmation { message: impl Into, callback_handle: PickerHandle, ) -> Self { - let title = title.into(); - let message = message.into(); + Self::with_target( + editor, + title.into(), + message.into(), + "Accept", + "Cancel", + false, + ConfirmationTarget::Callback(callback_handle), + ) + } + + /// Creates an editor-owned, multiline confirmation with explicit terminal actions. + pub fn new_actions( + editor: &Editor, + title: impl Into, + message: impl Into, + accept_label: impl Into, + cancel_label: impl Into, + accept: Action, + cancel: Action, + ) -> Self { + let accept_label = accept_label.into(); + let cancel_label = cancel_label.into(); + Self::with_target( + editor, + title.into(), + message.into(), + &accept_label, + &cancel_label, + true, + ConfirmationTarget::Actions { + accept: Box::new(accept), + cancel: Box::new(cancel), + }, + ) + } + + #[allow(clippy::too_many_arguments)] + fn with_target( + editor: &Editor, + title: String, + message: String, + accept_label: &str, + cancel_label: &str, + multiline: bool, + target: ConfirmationTarget, + ) -> Self { let style = editor.theme.ui_style.dialog.clone(); - let width = confirmation_width(editor.vwidth(), &message); + let accept_label = format!("[ {accept_label} ]"); + let cancel_label = format!("[ {cancel_label} ]"); + let (width, height) = confirmation_size( + editor.vwidth(), + editor.vheight(), + &message, + &accept_label, + &cancel_label, + multiline, + ); let x = editor.vwidth().saturating_sub(width + 2) / 2; - let y = editor.vheight().saturating_sub(4) / 2; + let y = editor.vheight().saturating_sub(height + 2) / 2; Self { dialog: Dialog::new( Some(title), x, y, width, - 2, + height, &style, BorderStyle::Single, &editor.theme, @@ -57,39 +122,74 @@ impl Confirmation { .with_surface_theme(&editor.theme, SurfaceRole::Dialog), message, accept_selected: false, - callback_handle, + target, + accept_label, + cancel_label, + multiline, + scroll: 0, style, theme: editor.theme.clone(), } } fn terminal_action(&self, accepted: bool) -> KeyAction { - let callback = if accepted { - PickerCallback::Selected(PickerItem { - id: "accept".to_string(), - icon: None, - label: "Accept".to_string(), - kind: Some("Proceed".to_string()), - annotation: None, - detail: None, - data: Value::Null, - matches: Vec::new(), - detail_matches: Vec::new(), - preview: None, - }) + match &self.target { + ConfirmationTarget::Callback(handle) => { + let callback = if accepted { + PickerCallback::Selected(PickerItem { + id: "accept".to_string(), + icon: None, + label: "Accept".to_string(), + kind: Some("Proceed".to_string()), + annotation: None, + detail: None, + data: Value::Null, + matches: Vec::new(), + detail_matches: Vec::new(), + preview: None, + }) + } else { + PickerCallback::Cancelled + }; + KeyAction::Multiple(vec![ + Action::NotifyPicker(*handle, Box::new(callback)), + Action::CloseDialog, + ]) + } + ConfirmationTarget::Actions { accept, cancel } => KeyAction::Multiple(vec![ + Action::CloseDialog, + if accepted { + accept.as_ref().clone() + } else { + cancel.as_ref().clone() + }, + ]), + } + } + + fn body_rows(&self) -> Vec { + if self.multiline { + wrap_text(&self.message, self.dialog.width).rows } else { - PickerCallback::Cancelled - }; - KeyAction::Multiple(vec![ - Action::NotifyPicker(self.callback_handle, Box::new(callback)), - Action::CloseDialog, - ]) + vec![truncate_display_width(&self.message, self.dialog.width)] + } + } + + fn body_height(&self) -> usize { + self.dialog.height.saturating_sub(1) + } + + fn max_scroll(&self) -> usize { + self.body_rows().len().saturating_sub(self.body_height()) } } impl Component for Confirmation { fn picker_handle(&self) -> Option { - Some(self.callback_handle) + match &self.target { + ConfirmationTarget::Callback(handle) => Some(*handle), + ConfirmationTarget::Actions { .. } => None, + } } fn set_theme(&mut self, theme: &Theme) { @@ -99,19 +199,41 @@ impl Component for Confirmation { } fn resize(&mut self, viewport_width: usize, viewport_height: usize) -> bool { - self.dialog.width = confirmation_width(viewport_width, &self.message); + (self.dialog.width, self.dialog.height) = confirmation_size( + viewport_width, + viewport_height, + &self.message, + &self.accept_label, + &self.cancel_label, + self.multiline, + ); self.dialog.x = viewport_width.saturating_sub(self.dialog.width + 2) / 2; - self.dialog.y = viewport_height.saturating_sub(4) / 2; + self.dialog.y = viewport_height.saturating_sub(self.dialog.height + 2) / 2; + self.scroll = self.scroll.min(self.max_scroll()); true } fn draw(&self, buffer: &mut RenderBuffer) -> anyhow::Result<()> { self.dialog.draw(buffer)?; - let message = truncate_display_width(&self.message, self.dialog.width); - buffer.set_text(self.dialog.x + 1, self.dialog.y + 1, &message, &self.style); + let rows = self.body_rows(); + for (offset, row) in rows + .iter() + .skip(self.scroll) + .take(self.body_height()) + .enumerate() + { + buffer.set_text( + self.dialog.x + 1, + self.dialog.y + 1 + offset, + row, + &self.style, + ); + } - let buttons_width = display_width(ACCEPT_LABEL) + BUTTON_GAP + display_width(CANCEL_LABEL); + let buttons_width = + display_width(&self.accept_label) + BUTTON_GAP + display_width(&self.cancel_label); let button_x = self.dialog.x + 1 + self.dialog.width.saturating_sub(buttons_width) / 2; + let button_y = self.dialog.y + self.dialog.height; let selected = self.theme.selected_style( &self.style, &self.theme.ui_style.picker_selected_item, @@ -119,8 +241,8 @@ impl Component for Confirmation { ); buffer.set_text( button_x, - self.dialog.y + 2, - ACCEPT_LABEL, + button_y, + &self.accept_label, if self.accept_selected { &selected } else { @@ -128,9 +250,9 @@ impl Component for Confirmation { }, ); buffer.set_text( - button_x + display_width(ACCEPT_LABEL) + BUTTON_GAP, - self.dialog.y + 2, - CANCEL_LABEL, + button_x + display_width(&self.accept_label) + BUTTON_GAP, + button_y, + &self.cancel_label, if self.accept_selected { &self.style } else { @@ -156,6 +278,14 @@ impl Component for Confirmation { self.accept_selected = false; Some(KeyAction::Single(Action::Refresh)) } + (KeyCode::Up | KeyCode::Char('k'), _) if self.multiline => { + self.scroll = self.scroll.saturating_sub(1); + Some(KeyAction::Single(Action::Refresh)) + } + (KeyCode::Down | KeyCode::Char('j'), _) if self.multiline => { + self.scroll = self.scroll.saturating_add(1).min(self.max_scroll()); + Some(KeyAction::Single(Action::Refresh)) + } (KeyCode::Char('y' | 'Y'), _) => Some(self.terminal_action(true)), (KeyCode::Char('n' | 'N'), _) => Some(self.terminal_action(false)), (KeyCode::Enter, _) => Some(self.terminal_action(self.accept_selected)), @@ -164,10 +294,35 @@ impl Component for Confirmation { } } -fn confirmation_width(viewport_width: usize, message: &str) -> usize { - let desired = display_width(message) - .max(display_width(ACCEPT_LABEL) + BUTTON_GAP + display_width(CANCEL_LABEL)); - desired.min(60).min(viewport_width.saturating_sub(2)).max(1) +fn confirmation_size( + viewport_width: usize, + viewport_height: usize, + message: &str, + accept_label: &str, + cancel_label: &str, + multiline: bool, +) -> (usize, usize) { + let buttons_width = display_width(accept_label) + BUTTON_GAP + display_width(cancel_label); + let desired_width = message + .lines() + .map(display_width) + .max() + .unwrap_or_default() + .max(buttons_width); + let max_width = if multiline { 76 } else { 60 }; + let width = desired_width + .min(max_width) + .min(viewport_width.saturating_sub(2)) + .max(1); + if !multiline { + return (width, 2.min(viewport_height.saturating_sub(2))); + } + let body_rows = wrap_text(message, width).rows.len().max(1); + let height = body_rows + .saturating_add(1) + .min(viewport_height.saturating_sub(2)) + .max(2.min(viewport_height.saturating_sub(2))); + (width, height) } #[cfg(test)] @@ -240,4 +395,69 @@ mod tests { assert_eq!(confirmation.dialog.height, 2); assert!(confirmation.dialog.width <= 60); } + + #[test] + fn editor_confirmation_wraps_multiline_details_and_defaults_to_the_safe_action() { + let editor = editor(); + let digest = "a".repeat(64); + let mut confirmation = Confirmation::new_actions( + &editor, + "Approve native grammar", + format!( + "Native grammars execute inside Red.\n\ngo: {digest}\n\nApproval is limited to these exact bytes." + ), + "Approve and install", + "Back", + Action::Print("approved".to_string()), + Action::Print("back".to_string()), + ); + let mut buffer = RenderBuffer::new(80, 20, &Style::default()); + + confirmation.draw(&mut buffer).unwrap(); + + assert!(confirmation.dialog.height > 2); + assert!(confirmation.dialog.width <= 76); + assert_eq!( + confirmation.handle_event(&key(KeyCode::Enter)), + Some(KeyAction::Multiple(vec![ + Action::CloseDialog, + Action::Print("back".to_string()), + ])) + ); + + confirmation.handle_event(&key(KeyCode::Left)); + assert_eq!( + confirmation.handle_event(&key(KeyCode::Enter)), + Some(KeyAction::Multiple(vec![ + Action::CloseDialog, + Action::Print("approved".to_string()), + ])) + ); + } + + #[test] + fn multiline_confirmation_scrolls_inside_a_small_viewport() { + let editor = editor(); + let mut confirmation = Confirmation::new_actions( + &editor, + "Approve native grammars", + (0..12) + .map(|index| format!("language-{index}: {}", "a".repeat(64))) + .collect::>() + .join("\n"), + "Approve exact bytes", + "Back", + Action::Print("approved".to_string()), + Action::Print("back".to_string()), + ); + confirmation.resize(44, 10); + let mut buffer = RenderBuffer::new(44, 10, &Style::default()); + + confirmation.draw(&mut buffer).unwrap(); + assert!(confirmation.max_scroll() > 0); + assert_eq!(confirmation.scroll, 0); + + confirmation.handle_event(&key(KeyCode::Down)); + assert_eq!(confirmation.scroll, 1); + } } diff --git a/src/ui/picker.rs b/src/ui/picker.rs index e8d2992..e0d98e7 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -54,6 +54,8 @@ const LOCATION_PREVIEW_CACHE_CAPACITY: usize = 8; const COMMAND_COLUMN_GAP: usize = 2; const PICKER_ICON_WIDTH: usize = 2; const PICKER_ITEM_PREFIX_WIDTH: usize = 2 + PICKER_ICON_WIDTH; +const INTRINSIC_COLUMN_GAP: usize = 2; +const INTRINSIC_FOOTER_GAP: usize = 4; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] @@ -178,6 +180,7 @@ pub enum PickerPresentation { pub enum PickerUpdate { Items(Vec), Query(String), + Selection(String), Status(Option), Busy(bool), Preview(Option), @@ -316,6 +319,17 @@ pub struct Picker { input_position: PickerInputPosition, icons: PickerIconsConfig, presentation: PickerPresentation, + viewport_width: usize, + viewport_height: usize, + content_sizing: Option, + status_on_query_line: bool, +} + +#[derive(Debug, Clone, Copy)] +struct PickerContentSizing { + min_width: Option, + max_width: usize, + max_rows: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -472,11 +486,41 @@ impl Picker { input_position: editor.picker_input_position(), icons: editor.picker_icons(), presentation, + viewport_width: editor.vwidth(), + viewport_height: editor.vheight(), + content_sizing: None, + status_on_query_line: false, } } fn resize_to_viewport(&mut self, total_width: usize, total_height: usize) { - let geometry = Self::geometry_for_viewport(total_width, total_height, self.presentation); + self.viewport_width = total_width; + self.viewport_height = total_height; + let geometry = self.content_sizing.map_or_else( + || Self::geometry_for_viewport(total_width, total_height, self.presentation), + |sizing| { + let available_width = total_width.saturating_sub(2); + let min_width = sizing.min_width.unwrap_or(48).min(available_width); + let desired_width = sizing.min_width.map_or(sizing.max_width, |_| { + self.intrinsic_content_width() + .clamp(min_width, sizing.max_width.max(min_width)) + }); + let width = desired_width.min(available_width).max(min_width); + let item_count = self + .dynamic_items + .as_ref() + .map(Vec::len) + .unwrap_or_else(|| self.items.len()); + let rows = item_count.clamp(4, sizing.max_rows.max(4)); + let height = rows.saturating_add(3).min(total_height.saturating_sub(1)); + PickerRect { + x: total_width.saturating_sub(width.saturating_add(2)) / 2, + y: total_height.saturating_sub(height.saturating_add(1)) / 2, + width, + height, + } + }, + ); self.x = geometry.x; self.y = geometry.y; self.width = geometry.width; @@ -488,6 +532,59 @@ impl Picker { self.sync_list_bounds(); } + fn intrinsic_content_width(&self) -> usize { + let item_width = self.dynamic_items.as_ref().map_or_else( + || { + self.items + .iter() + .map(|item| PICKER_ITEM_PREFIX_WIDTH + display_width(item)) + .max() + .unwrap_or_default() + }, + |items| { + items + .iter() + .map(|item| { + let annotation_width = item + .annotation + .as_deref() + .filter(|annotation| !annotation.is_empty()) + .map_or(0, |annotation| { + INTRINSIC_COLUMN_GAP + + display_width(annotation) + + item + .data + .get("annotation_right_margin") + .and_then(Value::as_u64) + .and_then(|margin| usize::try_from(margin).ok()) + .unwrap_or_default() + }); + let detail_width = item + .detail + .as_deref() + .filter(|detail| !detail.is_empty()) + .map_or(0, |detail| INTRINSIC_COLUMN_GAP + display_width(detail)); + PICKER_ITEM_PREFIX_WIDTH + + display_width(&item.label) + + annotation_width + + detail_width + }) + .max() + .unwrap_or_default() + }, + ); + let prompt_width = 3 + self.placeholder.as_deref().map_or(0, display_width); + let status_width = self.status.as_deref().map_or(0, |status| { + display_width(status) + 2 + usize::from(self.busy_since.is_some()) * 2 + }); + let footer_width = if prompt_width > 3 && status_width > 0 { + prompt_width + INTRINSIC_FOOTER_GAP + status_width + } else { + prompt_width.max(status_width) + }; + item_width.max(footer_width) + } + pub(crate) fn apply_theme(&mut self, theme: &Theme) { let item_style = theme.ui_style.picker_item.clone(); let selected_style = theme.selected_style( @@ -695,6 +792,7 @@ impl Picker { self.dynamic_items = Some(items); let search = self.search.clone(); self.filter(&search); + self.resize_to_viewport(self.viewport_width, self.viewport_height); self.reset_preview_scroll_if_selection_changed(previous); } @@ -709,6 +807,7 @@ impl Picker { self.dynamic_items = Some(items); let query = self.search.clone(); self.filter(&query); + self.resize_to_viewport(self.viewport_width, self.viewport_height); if let Some(selected_id) = selected_id { self.select_dynamic_id(&selected_id); } @@ -722,6 +821,7 @@ impl Picker { self.filter(&query); self.reset_preview_scroll_if_selection_changed(previous); } + PickerUpdate::Selection(id) => self.select_dynamic_id(&id), PickerUpdate::Status(status) => self.status = status, PickerUpdate::Busy(busy) => self.set_busy(busy), PickerUpdate::Preview(preview) => self.preview = preview, @@ -1392,9 +1492,63 @@ impl Picker { } } + fn prompt_status(&self) -> Option { + let command_status = self + .selected_dynamic_item() + .filter(|item| item.kind.as_deref() == Some("Command")) + .map(|item| { + let description = item + .data + .get("description") + .and_then(Value::as_str) + .unwrap_or_default(); + let total = self.dynamic_items.as_ref().map_or(0, Vec::len); + let visible = self.visible_dynamic_items.len(); + if description.is_empty() { + format!("{visible}/{total} commands") + } else { + format!("{description} · {visible}/{total} commands") + } + }); + let file_status = self + .selected_dynamic_item() + .filter(|item| item.kind.as_deref() == Some("FilePath")) + .map(|_| { + let total = self.dynamic_items.as_ref().map_or(0, Vec::len); + let visible = self.visible_dynamic_items.len(); + format!("{visible}/{total}") + }); + command_status + .as_deref() + .or(self.status.as_deref()) + .or(file_status.as_deref()) + .map(|status| { + if let Some(since) = self.busy_since { + format!( + "{} {status}", + spinner_frame(since.elapsed().as_millis() as u64) + ) + } else { + status.to_string() + } + }) + .map(|status| truncate_display_width(&status, self.width.saturating_sub(4))) + .map(|status| format!(" {status} ")) + } + + fn prompt_query_width(&self, status: Option<&str>) -> usize { + let inline_status_width = status + .filter(|_| self.status_on_query_line) + .map_or(0, display_width); + self.width + .saturating_sub(2) + .saturating_sub(inline_status_width) + } + fn draw_prompt(&self, buffer: &mut RenderBuffer, layout: PickerLayout) { self.draw_separator(buffer, layout.separator_y); - let query_width = self.width.saturating_sub(2); + let status = self.prompt_status(); + let query_width = self.prompt_query_width(status.as_deref()); buffer.set_text( self.x + 1, layout.query_y, @@ -1422,50 +1576,15 @@ impl Picker { ); } - let command_status = self - .selected_dynamic_item() - .filter(|item| item.kind.as_deref() == Some("Command")) - .map(|item| { - let description = item - .data - .get("description") - .and_then(Value::as_str) - .unwrap_or_default(); - let total = self.dynamic_items.as_ref().map_or(0, Vec::len); - let visible = self.visible_dynamic_items.len(); - if description.is_empty() { - format!("{visible}/{total} commands") - } else { - format!("{description} · {visible}/{total} commands") - } - }); - let file_status = self - .selected_dynamic_item() - .filter(|item| item.kind.as_deref() == Some("FilePath")) - .map(|_| { - let total = self.dynamic_items.as_ref().map_or(0, Vec::len); - let visible = self.visible_dynamic_items.len(); - format!("{visible}/{total}") - }); - if let Some(status) = command_status - .as_deref() - .or(self.status.as_deref()) - .or(file_status.as_deref()) - { - let status = if let Some(since) = self.busy_since { - Cow::Owned(format!( - "{} {status}", - spinner_frame(since.elapsed().as_millis() as u64) - )) - } else { - Cow::Borrowed(status) - }; - let status = truncate_display_width(&status, self.width.saturating_sub(4)); - let status = format!(" {status} "); + if let Some(status) = status { let status_x = self.x + self.width + 1 - display_width(&status); buffer.set_text( status_x, - layout.separator_y, + if self.status_on_query_line { + layout.query_y + } else { + layout.separator_y + }, &status, &self.theme.ui_style.picker_prompt, ); @@ -1596,7 +1715,52 @@ impl Picker { let separator_width = usize::from(detail_width > 0) * detail_separator_width; let primary_width = content_width.saturating_sub(detail_width + separator_width); let label_x = x; - let label_width = display_width(&item.label).min(primary_width); + let right_aligned_annotation = + item.data.get("annotation_align").and_then(Value::as_str) == Some("right"); + let full_annotation = item.annotation.as_deref().filter(|value| !value.is_empty()); + let compact_annotation = item + .data + .get("compact_annotation") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + let annotation_right_margin = item + .data + .get("annotation_right_margin") + .and_then(Value::as_u64) + .and_then(|margin| usize::try_from(margin).ok()) + .unwrap_or_default() + .min(primary_width); + let desired_label_width = display_width(&item.label).min(primary_width); + let annotation_capacity = primary_width.saturating_sub( + desired_label_width + INTRINSIC_COLUMN_GAP + annotation_right_margin, + ); + let (annotation, using_compact_annotation) = if right_aligned_annotation { + match full_annotation { + Some(annotation) if display_width(annotation) <= annotation_capacity => { + (Some(annotation), false) + } + Some(annotation) => ( + compact_annotation.or(Some(annotation)), + compact_annotation.is_some(), + ), + None => (None, false), + } + } else { + (full_annotation, false) + }; + let annotation_width = annotation + .filter(|_| right_aligned_annotation) + .map_or(0, |annotation| { + display_width(annotation).min(annotation_capacity) + }); + let annotation_gap = usize::from(annotation_width > 0) * INTRINSIC_COLUMN_GAP; + let label_available = if annotation_width > 0 { + primary_width + .saturating_sub(annotation_width + annotation_gap + annotation_right_margin) + } else { + primary_width + }; + let label_width = display_width(&item.label).min(label_available); let label_style = self.result_label_style(&row_style); let match_style = if derived_label_matches.is_some() { self.result_filter_match_style(&label_style) @@ -1615,12 +1779,32 @@ impl Picker { ); let annotation_remaining = primary_width.saturating_sub(used); - if annotation_remaining > 1 { - if let Some(annotation) = - item.annotation.as_deref().filter(|value| !value.is_empty()) - { + if let Some(annotation) = annotation { + if right_aligned_annotation && annotation_width > 0 { + let annotation_style = self.result_annotation_style(&row_style, is_selected); + let annotation_match_style = self.result_filter_match_style(&annotation_style); + let annotation_matches = if using_compact_annotation { + &[] + } else { + filter_highlights + .as_ref() + .map(|highlights| highlights.annotation.as_slice()) + .unwrap_or_default() + }; + self.draw_text_with_matches( + buffer, + label_x + + primary_width + .saturating_sub(annotation_width + annotation_right_margin), + y, + annotation, + annotation_width, + &annotation_style, + &annotation_match_style, + annotation_matches, + ); + } else if annotation_remaining > 1 { let annotation_style = self.result_annotation_style(&row_style, is_selected); - let annotation_x = label_x + used + 1; let annotation_match_style = self.result_filter_match_style(&annotation_style); let annotation_matches = filter_highlights .as_ref() @@ -1628,7 +1812,7 @@ impl Picker { .unwrap_or_default(); self.draw_text_with_matches( buffer, - annotation_x, + label_x + used + 1, y, annotation, annotation_remaining.saturating_sub(1), @@ -2988,7 +3172,8 @@ impl Component for Picker { } fn cursor_position(&self) -> Option<(usize, usize)> { - let query_width = self.width.saturating_sub(2); + let status = self.prompt_status(); + let query_width = self.prompt_query_width(status.as_deref()); let visible_query = display_width_tail(&self.search, query_width); let cx = self.x + 3 + display_width(visible_query).min(query_width.saturating_sub(1)); let cy = self.layout().query_y; @@ -3079,6 +3264,8 @@ pub struct PickerBuilder { status: Option, busy: bool, history_key: Option, + content_sizing: Option, + status_on_query_line: bool, } impl Default for PickerBuilder { @@ -3102,6 +3289,8 @@ impl PickerBuilder { status: None, busy: false, history_key: None, + content_sizing: None, + status_on_query_line: false, } } @@ -3182,6 +3371,30 @@ impl PickerBuilder { self } + /// Fits an editor-owned picker to its rows while retaining a bounded, readable width. + pub(crate) fn content_sized(mut self, max_width: usize, max_rows: usize) -> Self { + self.content_sizing = Some(PickerContentSizing { + min_width: None, + max_width, + max_rows, + }); + self + } + + /// Derives picker width from its complete item set and footer without resizing on filters. + pub(crate) fn fit_content_width(mut self, min_width: usize) -> Self { + if let Some(sizing) = &mut self.content_sizing { + sizing.min_width = Some(min_width); + } + self + } + + /// Places compact status text on the query row instead of cutting the separator line. + pub(crate) fn status_on_query_line(mut self) -> Self { + self.status_on_query_line = true; + self + } + pub fn build(self, editor: &Editor) -> Picker { let title = self.title; let structured_items = self.structured_items; @@ -3199,6 +3412,8 @@ impl PickerBuilder { let status = self.status; let busy = self.busy; let history_key = self.history_key; + let content_sizing = self.content_sizing; + let status_on_query_line = self.status_on_query_line; let mut picker = Picker::new(title, editor, &items, id); if let Some(structured_items) = structured_items { @@ -3219,6 +3434,9 @@ impl PickerBuilder { let history = editor.picker_history(&history_key).to_vec(); picker.set_history(history_key, history); } + picker.content_sizing = content_sizing; + picker.status_on_query_line = status_on_query_line; + picker.resize_to_viewport(editor.vwidth(), editor.vheight()); picker } @@ -5409,6 +5627,128 @@ mod tests { ); } + #[test] + fn dynamic_picker_selection_can_follow_an_async_item_update() { + let editor = test_editor(); + let mut picker = Picker::new_dynamic( + None, + &editor, + vec![dynamic_item("custom", "Add custom source")], + 13, + PickerOptions::default(), + ); + picker.apply_update( + 13, + PickerUpdate::Items(vec![ + dynamic_item("go", "Go language support"), + dynamic_item("swift", "Swift language support"), + dynamic_item("custom", "Add custom source"), + ]), + ); + + picker.apply_update(13, PickerUpdate::Selection("go".to_string())); + + assert_eq!( + picker.selected_dynamic_item().map(|item| item.id.as_str()), + Some("go") + ); + } + + #[test] + fn content_sized_picker_keeps_primary_rows_full_width_without_a_preview() { + let editor = test_editor_with_theme_and_size(Theme::default(), 180, 50); + let mut go = dynamic_item("go", "Go language support"); + go.annotation = Some("Official · v0.1.0".to_string()); + go.data = json!({ + "annotation_align": "right", + "annotation_right_margin": 2, + "compact_annotation": "v0.1.0", + }); + let mut picker = Picker::builder() + .title("Language packs") + .structured_items(vec![ + go, + dynamic_item("swift", "Swift language support"), + dynamic_item("custom", "+ Add custom source…"), + ]) + .placeholder("Search language packs") + .status("2 packs · Enter open") + .content_sized(88, 14) + .fit_content_width(56) + .status_on_query_line() + .build(&editor); + let mut buffer = RenderBuffer::new(180, 50, &Style::default()); + + picker.draw(&mut buffer).unwrap(); + + assert_eq!(picker.width, 56); + assert_eq!(picker.height, 7); + assert!(picker.layout().preview.is_none()); + let result_row = render_row(&buffer, picker.layout().results.y); + assert!(result_row.contains("Go language support")); + assert!(result_row + .trim_end() + .trim_end_matches('│') + .trim_end() + .ends_with("Official · v0.1.0")); + assert!(result_row.contains("Official · v0.1.0 │")); + assert!(render_row(&buffer, picker.layout().results.y + 2).contains("+ Add custom source…")); + assert!(!render_row(&buffer, picker.layout().separator_y).contains("2 packs")); + assert!(render_row(&buffer, picker.layout().query_y).contains("2 packs · Enter open")); + + picker.filter("go"); + assert_eq!(picker.width, 56); + } + + #[test] + fn content_sized_picker_stays_inside_a_small_viewport() { + let editor = test_editor_with_theme_and_size(Theme::default(), 42, 12); + let picker = Picker::builder() + .title("Language packs") + .structured_items(vec![ + dynamic_item("go", "Go language support"), + dynamic_item("swift", "Swift language support"), + dynamic_item("custom", "+ Add custom source…"), + ]) + .content_sized(88, 14) + .fit_content_width(56) + .build(&editor); + let mut buffer = RenderBuffer::new(42, 12, &Style::default()); + + picker.draw(&mut buffer).unwrap(); + + assert!(picker.width <= 40); + assert!(picker.height <= 10); + assert!(picker.dialog.x + picker.width + 2 <= 42); + assert!(picker.dialog.y + picker.dialog.height + 2 <= 12); + } + + #[test] + fn narrow_intrinsic_picker_preserves_names_before_collapsing_metadata() { + let editor = test_editor_with_theme_and_size(Theme::default(), 42, 12); + let mut go = dynamic_item("go", "Go language support"); + go.annotation = Some("Official · v0.1.0".to_string()); + go.data = json!({ + "annotation_align": "right", + "annotation_right_margin": 2, + "compact_annotation": "v0.1.0", + }); + let picker = Picker::builder() + .title("Language packs") + .structured_items(vec![go]) + .content_sized(88, 14) + .fit_content_width(56) + .build(&editor); + let mut buffer = RenderBuffer::new(42, 12, &Style::default()); + + picker.draw(&mut buffer).unwrap(); + + let row = render_row(&buffer, picker.layout().results.y); + assert!(row.contains("Go language support")); + assert!(row.contains("v0.1.0")); + assert!(!row.contains("Official")); + } + #[test] fn filtering_dynamic_items_keeps_references_to_the_original_items() { let editor = test_editor();