Hover messages now display:
Planned in the future:
72K45XKDA7R3R4I7ZOMZAA2433VR4SD7C7I5K6PCE6RTYL545GGQC <strong>{{ title }}</strong>{{ authors }} • {{ relative_timestamp }}<hr /><table><tbody><tr><th>{{ change_id_header }}</th><td><code>{{ change_id }}</code></td></tr><tr><th>{{ absolute_timestamp_header }}</th><td>{{ absolute_timestamp }}</td></tr></tbody></table>{% if let Some(description) = description %}{{ description }}{% endif %}
}// TODO: different rendering modes? e.g. render only at boundaries between lines, render only on cursors, etcpub fn render(env: &napi::Env,program_state: &ExtensionState,editor: &vscode_sys::TextEditor,) -> Result<(), napi::Error> {let editor_document = editor.get_document()?;let document_uri = editor_document.get_uri()?;let Some((repository_path, open_repository)) = program_state.repositories.get_open_repository(env, &document_uri)?else {tracing::warn!(message = "No repository for URI",uri = document_uri.to_string()?);return Ok(());};let absolute_file_path = Utf8PathBuf::from(document_uri.get_fs_path()?);let relative_file_path =absolute_file_path.strip_prefix(&repository_path).map_err(|error| {napi::Error::from_reason(format!("Failed to strip prefix {repository_path} from {absolute_file_path}: {error}"))})?;let open_file = open_repository.repository.get_open_file(relative_file_path).ok_or_else(|| {napi::Error::from_reason(format!("unable to get open file for {relative_file_path}"))})?;// TODO: don't initialize herelet localization_context = l10n_embed::Context::new(locale!("en-US"), false);let mut decoration_options_list = Vec::new();for editor_selection in editor.get_selections()? {let start_line = editor_selection.get_start()?.get_line()?;let end_line: u32 = editor_selection.get_end()?.get_line()?;let selected_range = (start_line as usize)..=(end_line as usize);let credits = open_file.credit(selected_range);for credit_span in credits {for line in credit_span.lines {let annotation_range = line_annotation::range(env, open_file, line)?;let line_annotation_text = line_annotation::render(&localization_context,&open_repository.repository,open_file,&credit_span.value,)?;let hover_message = hover::render(env,&localization_context,&open_repository.repository,&credit_span.value,)?;let after = vscode_sys::ThemableDecorationAttachmentRenderOptions::new(env)?.content_text(&line_annotation_text)?;let render_options =vscode_sys::DecorationInstanceRenderOptions::new(env)?.after(after)?;let decoration_options =vscode_sys::DecorationOptions::new(env, &annotation_range)?.hover_message(&hover_message)?.render_options(render_options)?;decoration_options_list.push(decoration_options);}}}let decoration_type = program_state.decoration_type.get_inner(env)?;editor.set_decorations(decoration_type, decoration_options_list)?;Ok(())
fn get_lines_selected(editor_selections: Vec<vscode_sys::Selection>,) -> Result<Vec<RangeInclusive<usize>>, napi::Error> {let mut credits = Vec::with_capacity(editor_selections.len());
pub fn range<'env>(env: &'env napi::Env,open_file: &OpenFile,line: usize,) -> Result<vscode_sys::Range<'env>, napi::Error> {let current_line_contents = open_file.contents.text.get_line(line, ropey::LineType::LF_CR).ok_or_else(|| napi::Error::from_reason(format!("no contents for line {line}")))?;let final_character_offset = current_line_contents.len() as u32;
for editor_selection in &editor_selections {let start_line = editor_selection.get_start()?.get_line()?;let end_line: u32 = editor_selection.get_end()?.get_line()?;credits.push((start_line as usize)..=(end_line as usize));}
let start = vscode_sys::Position::new(env, line as u32, final_character_offset)?;let end = vscode_sys::Position::new(env, line as u32, final_character_offset)?;
credits: impl Iterator<Item = Span<CreditSource>>,) -> Result<Vec<vscode_sys::DecorationOptions<'env>>, napi::Error> {let mut decoration_options_list = Vec::new();for credit in credits {tracing::debug!(?credit);let annotation = match credit.value {CreditSource::Tracked(change_id) => {let change_header = repository.get_change_header(&change_id).ok_or_else(|| {napi::Error::from_reason(format!("unable to get change {change_id:#?}"))})?;let authors = repository.authors_for_change(change_header).into_iter().map(|author_source| match author_source {pijul_extension::author::AuthorSource::Local(_identity) => {AuthorKind::Local}pijul_extension::author::AuthorSource::Remote(identity) => {AuthorKind::Remote {username: &identity.config.author.username,}}pijul_extension::author::AuthorSource::Unknown(public_key_signature) => {AuthorKind::Unknown {public_key_signature,}
credit_source: &CreditSource,) -> Result<String, napi::Error> {let annotation = match credit_source {CreditSource::Tracked(change_id) => {let change_header = repository.get_change_header(change_id).ok_or_else(|| {napi::Error::from_reason(format!("unable to get change {change_id:#?}"))})?;let authors = repository.authors_for_change(change_header).into_iter().map(|author_source| match author_source {pijul_extension::author::AuthorSource::Local(_identity) => AuthorKind::Local,pijul_extension::author::AuthorSource::Remote(identity) => AuthorKind::Remote {username: &identity.config.author.username,},pijul_extension::author::AuthorSource::Unknown(public_key_signature) => {AuthorKind::Unknown {public_key_signature,
InlineCreditAnnotation::Tracked {authors: l10n_embed::list::AndList::new(authors),timestamp: change_header.timestamp,message: &change_header.message,}
LineAnnotation::Tracked {authors: l10n_embed::list::AndList::new(authors),timestamp: change_header.timestamp,message: &change_header.message,
CreditSource::Untracked => InlineCreditAnnotation::Untracked {timestamp: Timestamp::try_from(open_file.contents.modified_time).map_err(|error| {napi::Error::from_reason(format!("Failed to parse timestamp {:#?}: {error}",open_file.contents.modified_time))},)?,},};let mut localized_annotation = String::new();annotation.localize(localization_context, &mut localized_annotation);let hover_message_text = match credit.value {CreditSource::Tracked(change_id) => {let change_header = repository.get_change_header(&change_id).ok_or_else(|| {napi::Error::from_reason(format!("unable to get change {change_id:#?}"))})?;match &change_header.description {Some(description) => {format!("<h2>{}</h2><p>{}</p>", change_header.message, description)}None => change_header.message.clone(),}}CreditSource::Untracked => String::new(),};for line in credit.lines {let current_line_contents = open_file.contents.text.get_line(line, ropey::LineType::LF_CR).ok_or_else(|| napi::Error::from_reason(format!("no contents for line {line}")))?;let final_character_offset = current_line_contents.len() as u32;let start = vscode_sys::Position::new(env, line as u32, final_character_offset)?;let end = vscode_sys::Position::new(env, line as u32, final_character_offset)?;let range = vscode_sys::Range::new(env, &start, &end)?;let mut hover_message =vscode_sys::MarkdownString::new(env, &hover_message_text, false)?;hover_message.inner.set_named_property("supportHtml", true)?;let after = vscode_sys::ThemableDecorationAttachmentRenderOptions::new(env)?.content_text(&localized_annotation)?;let render_options =vscode_sys::DecorationInstanceRenderOptions::new(env)?.after(after)?;let decoration_options = vscode_sys::DecorationOptions::new(env, &range)?.hover_message(&hover_message)?.render_options(render_options)?;decoration_options_list.push(decoration_options);
}Ok(decoration_options_list)}// TODO: different rendering modes? e.g. render only at boundaries between lines, render only on cursors, etcpub fn render(env: &napi::Env,program_state: &ExtensionState,editor: &vscode_sys::TextEditor,selections: Vec<vscode_sys::Selection>,) -> Result<(), napi::Error> {let editor_document = editor.get_document()?;let document_uri = editor_document.get_uri()?;let Some((repository_path, open_repository)) = program_state.repositories.get_open_repository(env, &document_uri)?else {return Ok(());};let absolute_file_path = Utf8PathBuf::from(document_uri.get_fs_path()?);let relative_file_path =absolute_file_path.strip_prefix(&repository_path).map_err(|error| {
CreditSource::Untracked => LineAnnotation::Untracked {// TODO: return the current time if `TextDocument::get_is_dirty()`timestamp: Timestamp::try_from(open_file.contents.modified_time).map_err(|error| {
})?;let localization_context = l10n_embed::Context::new(locale!("en-US"), false);let lines_selected = get_lines_selected(selections)?;let open_file = open_repository.repository.get_open_file(relative_file_path).ok_or_else(|| {napi::Error::from_reason(format!("unable to get open file for {relative_file_path}"))})?;let credits = lines_selected.into_iter().flat_map(|line_range| open_file.credit(line_range));let decoration_options = decoration_options(env,&localization_context,&open_repository.repository,open_file,credits,)?;
})?,},};
let decoration_type = program_state.decoration_type.get_inner(env)?;editor.set_decorations(decoration_type, decoration_options)?;
let mut localized_annotation = String::new();annotation.localize(localization_context, &mut localized_annotation);
use std::borrow::Cow;use askama::Template;use icu_datetime::{DateTimeFormatter, DateTimeFormatterPreferences};use jiff::tz::TimeZone;use jiff_icu::ConvertFrom;use l10n_embed::Localize;use l10n_embed_derive::localize;use libpijul::Base32;use pijul_extension::FileSystemRepository;use pijul_extension::author::AuthorSource;use pijul_extension::file_system::changes::CreditSource;use crate::vscode_sys;#[localize("l10n/**/inline_credit/hover.ftl")]enum HoverHeading {AbsoluteTimestamp,ChangeId,}#[derive(askama::Template)]#[template(path = "hover/tracked.html")]struct TrackedHoverTemplate<'change_header> {title: &'change_header str,authors: String,relative_timestamp: String,absolute_timestamp_header: String,absolute_timestamp: String,change_id_header: String,// TODO: color/emphasize unique portion of ID (e.g. *ABC*DEFGHIJKL)change_id: String,description: Option<&'change_header str>,}#[derive(askama::Template)]#[template(path = "hover/untracked.html")]struct UntrackedHoverTemplate;pub fn render<'env>(env: &'env napi::Env,localization_context: &l10n_embed::Context,repository: &FileSystemRepository,credit_source: &CreditSource,) -> Result<vscode_sys::MarkdownString<'env>, napi::Error> {let rendered_html_result = match credit_source {CreditSource::Tracked(change_id) => {let change_header = repository.get_change_header(change_id).ok_or_else(|| {napi::Error::from_reason(format!("unable to get change {change_id:#?}"))})?;let mut authors = String::new();let authors_list = l10n_embed::list::AndList::new(repository.authors_for_change(change_header).into_iter().map(|author_source| match author_source {AuthorSource::Local(identity) | AuthorSource::Remote(identity) => {Cow::from(format!("{} ({})",identity.config.author.display_name,identity.config.author.username))}AuthorSource::Unknown(public_key_hash) => Cow::from(public_key_hash),}).collect(),);authors_list.localize(localization_context, &mut authors);let mut relative_timestamp = String::new();change_header.timestamp.localize(localization_context, &mut relative_timestamp);let mut absolute_timestamp_header = String::new();let mut change_id_header = String::new();HoverHeading::AbsoluteTimestamp.localize(localization_context, &mut absolute_timestamp_header);HoverHeading::ChangeId.localize(localization_context, &mut change_id_header);// TODO: properly localize using l10n_embedlet zoned_timestamp = change_header.timestamp.to_zoned(TimeZone::system());let icu_timestamp = icu_time::ZonedDateTime::<icu_calendar::Iso,icu_time::TimeZoneInfo<icu_time::zone::models::Full>,>::convert_from(&zoned_timestamp);let absolute_timestamp_formatter = DateTimeFormatter::try_new(DateTimeFormatterPreferences::from(&localization_context.locale),icu_datetime::fieldsets::YMDT::medium().with_zone(icu_datetime::fieldsets::zone::SpecificShort),).map_err(|error| {napi::Error::from_reason(format!("Failed to create datetime formatter: {error}"))})?;let absolute_timestamp = absolute_timestamp_formatter.format(&icu_timestamp).to_string();let hover_template = TrackedHoverTemplate {title: &change_header.message,authors,relative_timestamp,absolute_timestamp_header,absolute_timestamp,change_id_header,change_id: change_id.to_base32(),description: change_header.description.as_deref(),};hover_template.render()}CreditSource::Untracked => {let hover_template = UntrackedHoverTemplate;hover_template.render()}};let rendered_html = rendered_html_result.map_err(|error| {napi::Error::from_reason(format!("Failed to render hover message: {error}"))})?;let mut hover_message = vscode_sys::MarkdownString::new(env, &rendered_html, false)?;hover_message.set_support_html(true)?;Ok(hover_message)}
absolute-timestamp = Timestampchange-id = Change ID
[[package]]name = "askama"version = "0.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"dependencies = ["askama_derive","itoa","percent-encoding","serde","serde_json",][[package]]name = "askama_derive"version = "0.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"dependencies = ["askama_parser","basic-toml","memchr","proc-macro2","quote","rustc-hash","serde","serde_derive","syn",]
name = "icu_calendar"version = "2.0.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "362941891d17750e05cd8fdfca4a89a86552825d625a937020ee1a65580da1f9"dependencies = ["calendrical_calculations","displaydoc","icu_calendar_data","icu_locale","icu_locale_core","icu_provider","ixdtf","tinystr","writeable","zerovec",][[package]]name = "icu_calendar_data"version = "2.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7219c8639ab936713a87b571eed2bc2615aa9137e8af6eb221446ee5644acc18"[[package]]
"zerovec",][[package]]name = "icu_datetime"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eea7ff7a9d7ce8f419930ec75b9821807cdbde7ec5f0157c322773a6ede6125f"dependencies = ["displaydoc","either","fixed_decimal","icu_calendar","icu_datetime_data","icu_decimal","icu_locale","icu_locale_core","icu_pattern","icu_plurals","icu_provider","icu_time","potential_utf","smallvec","tinystr","writeable",
name = "icu_time"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b49a81ac920c1a93e3165a5f16d16f5c15d9f19fc5dc1223c00af35f7a7c2bb6"dependencies = ["calendrical_calculations","displaydoc","icu_calendar","icu_locale_core","icu_provider","icu_time_data","ixdtf","serde","tinystr","writeable","zerotrie","zerovec",][[package]]name = "icu_time_data"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8472be4410d26a03d7208cae3a76c798dd6766e8226ab977cd8b2d349a6dbf08"[[package]]