use crate::{
buffer::{BufferEditKind, BufferEditShape},
ecs::components::{
buffer::{BufferEntity, EditorView, FocusedEditorView},
vim::VimModalState,
},
ecs::events::edit::{
PluginBufferEditProposalApplyRequested, PluginBufferEditProposalReceipt,
PluginBufferEditProposalReceiptOutcome, PluginBufferEditProposalReceiptOwner,
PluginBufferEditProposalReceiptRejectionReason, PluginBufferEditProposalRejectRequested,
PluginProposalId,
},
ecs::{
PendingPluginBufferEditProposal, PluginBufferEditDiffPreview, PluginProposalLane,
PluginProposalReceiptLog,
},
fs_utils::EscapedDisplayText,
presentation::ResolvedTheme,
vim::VimConfig,
};
use bevy::picking::{
Pickable,
events::{Click, Pointer},
};
use bevy::prelude::{
BackgroundColor, Color, DetectChanges, Entity, In, MessageWriter, Node, PositionType, Query,
Ref, Res, ResMut, Resource, Text, TextColor, TextFont, UiRect, Val, With,
};
use haalka::prelude::*;
use super::{
STATUS_BAR_HEIGHT,
status::{StatusLineKind, render_search_status},
style::{status_bar_color, status_error_text_color, status_text_color},
};
const STATUS_FONT_SIZE: f32 = 18.0;
type ProposalClick = In<(Entity, Pointer<Click>)>;
#[derive(Clone, Debug, Default, PartialEq, Resource)]
pub struct ChromeStatusLine {
text: String,
is_error: bool,
text_color: Color,
}
impl ChromeStatusLine {
fn new(text: String, kind: StatusLineKind, theme: &ResolvedTheme) -> Self {
let is_error = matches!(kind, StatusLineKind::Error);
Self {
text,
is_error,
text_color: if is_error {
status_error_text_color(theme)
} else {
status_text_color(theme)
},
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Resource)]
pub struct ChromeProposalLane {
text: String,
status: String,
diff_preview: String,
first_proposal: Option<PluginProposalId>,
text_color: Color,
background_color: Color,
}
impl ChromeProposalLane {
fn new(rendered: RenderedProposalLane, theme: &ResolvedTheme) -> Self {
let background_color = if rendered.text.is_empty()
&& rendered.status.is_empty()
&& rendered.diff_preview.is_empty()
{
Color::srgba(0.0, 0.0, 0.0, 0.0)
} else {
status_bar_color(theme)
};
Self {
text: rendered.text,
status: rendered.status,
diff_preview: rendered.diff_preview,
first_proposal: rendered.first_proposal,
text_color: status_text_color(theme),
background_color,
}
}
#[must_use]
const fn first_proposal(&self) -> Option<PluginProposalId> {
self.first_proposal
}
#[must_use]
fn display_text(&self) -> String {
[
self.text.as_str(),
self.status.as_str(),
self.diff_preview.as_str(),
]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" | ")
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct RenderedProposalLane {
text: String,
status: String,
diff_preview: String,
first_proposal: Option<PluginProposalId>,
}
pub fn spawn_chrome(world: &mut bevy::prelude::World) {
let _chrome_entity = chrome_root().spawn(world);
}
#[allow(clippy::needless_pass_by_value)]
pub fn sync_chrome_status_line(
vim_config: Res<VimConfig>,
theme: Res<ResolvedTheme>,
modal_query: Query<Ref<VimModalState>, (With<EditorView>, With<FocusedEditorView>)>,
mut chrome_status_line: ResMut<ChromeStatusLine>,
) {
let Some(modal_state) = modal_query.iter().next() else {
return;
};
if !modal_state.is_changed() && !vim_config.is_changed() && !theme.is_changed() {
return;
}
let vim_config = vim_config.into_inner();
let rendered = render_search_status(
&modal_state.editor.search,
&modal_state.editor.command,
&modal_state.editor.leader,
vim_config,
&modal_state.editor.status,
);
let next = ChromeStatusLine::new(rendered.text, rendered.kind, &theme);
if *chrome_status_line != next {
*chrome_status_line = next;
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn sync_chrome_proposal_lane(
theme: Res<ResolvedTheme>,
proposals: Option<Res<PluginProposalLane>>,
receipts: Option<Res<PluginProposalReceiptLog>>,
focused_view: Query<&EditorView, With<FocusedEditorView>>,
mut chrome_proposal_lane: ResMut<ChromeProposalLane>,
) {
let focused_buffer = focused_view.iter().next().map(|view| view.buffer);
let proposal_lane = proposals.as_deref();
let receipt_log = receipts.as_deref();
let rendered = render_proposal_lane(proposal_lane, receipt_log, focused_buffer);
let next = ChromeProposalLane::new(rendered, &theme);
if *chrome_proposal_lane != next {
*chrome_proposal_lane = next;
}
}
fn chrome_root() -> impl Element {
El::<Node>::new()
.with_node(|mut node| {
node.position_type = PositionType::Absolute;
node.left = Val::Px(0.0);
node.right = Val::Px(0.0);
node.bottom = Val::Px(0.0);
node.height = Val::Px(STATUS_BAR_HEIGHT);
node.padding = UiRect::horizontal(Val::Px(8.0));
})
.background_color_signal(
signal::from_resource_changed::<ResolvedTheme>()
.map_in(|theme| Some(BackgroundColor(status_bar_color(&theme)))),
)
.align_content(Align::new().left().center_y())
.child(status_text())
.child(proposal_lane())
}
fn status_text() -> impl Element {
El::<Text>::new()
.text_font(TextFont::from_font_size(STATUS_FONT_SIZE))
.text_signal(
signal::from_resource_changed::<ChromeStatusLine>()
.map_in(|status| Some(Text::new(status.text))),
)
.text_color_signal(
signal::from_resource_changed::<ChromeStatusLine>()
.map_in(|status| Some(TextColor(status.text_color))),
)
}
fn proposal_lane() -> impl Element {
El::<Node>::new()
.with_node(|mut node| {
node.position_type = PositionType::Absolute;
node.top = Val::Px(8.0);
node.right = Val::Px(8.0);
node.padding = UiRect::axes(Val::Px(8.0), Val::Px(4.0));
})
.background_color_signal(
signal::from_resource_changed::<ChromeProposalLane>()
.map_in(|lane| Some(BackgroundColor(lane.background_color))),
)
.child(
Row::<Node>::new()
.with_node(|mut node| {
node.column_gap = Val::Px(6.0);
})
.item(proposal_summary_text())
.item(proposal_control("Apply", apply_first_plugin_proposal))
.item(proposal_control("Reject", reject_first_plugin_proposal)),
)
}
fn proposal_summary_text() -> impl Element {
El::<Text>::new()
.text_font(TextFont::from_font_size(14.0))
.text_signal(
signal::from_resource_changed::<ChromeProposalLane>()
.map_in(|lane| Some(Text::new(lane.display_text()))),
)
.text_color_signal(
signal::from_resource_changed::<ChromeProposalLane>()
.map_in(|lane| Some(TextColor(lane.text_color))),
)
}
fn proposal_control<Marker>(
label: &'static str,
handler: impl bevy::prelude::IntoSystem<ProposalClick, (), Marker> + Send + Sync + 'static,
) -> impl Element {
El::<Node>::new()
.insert(Pickable::default())
.cursor(CursorIcon::System(SystemCursorIcon::Pointer))
.with_node(|mut node| {
node.padding = UiRect::axes(Val::Px(6.0), Val::Px(2.0));
})
.background_color_signal(
signal::from_resource_changed::<ChromeProposalLane>().map_in(|lane| {
let alpha = if lane.first_proposal().is_some() {
0.18
} else {
0.0
};
Some(BackgroundColor(Color::srgba(1.0, 1.0, 1.0, alpha)))
}),
)
.align_content(Align::center())
.on_click(handler)
.child(
El::<Text>::new()
.text_font(TextFont::from_font_size(12.0))
.text_signal(
signal::from_resource_changed::<ChromeProposalLane>().map_in(move |lane| {
let text = if lane.first_proposal().is_some() {
label
} else {
""
};
Some(Text::new(text))
}),
)
.text_color_signal(
signal::from_resource_changed::<ChromeProposalLane>()
.map_in(|lane| Some(TextColor(lane.text_color))),
),
)
}
#[allow(clippy::needless_pass_by_value)]
fn apply_first_plugin_proposal(
_: ProposalClick,
lane: Res<ChromeProposalLane>,
mut apply_requests: MessageWriter<PluginBufferEditProposalApplyRequested>,
) {
if let Some(request) = first_proposal_apply_request(&lane) {
let _sent = apply_requests.write(request);
}
}
#[allow(clippy::needless_pass_by_value)]
fn reject_first_plugin_proposal(
_: ProposalClick,
lane: Res<ChromeProposalLane>,
mut reject_requests: MessageWriter<PluginBufferEditProposalRejectRequested>,
) {
if let Some(request) = first_proposal_reject_request(&lane) {
let _sent = reject_requests.write(request);
}
}
fn first_proposal_apply_request(
lane: &ChromeProposalLane,
) -> Option<PluginBufferEditProposalApplyRequested> {
lane.first_proposal()
.map(|proposal| PluginBufferEditProposalApplyRequested { proposal })
}
fn first_proposal_reject_request(
lane: &ChromeProposalLane,
) -> Option<PluginBufferEditProposalRejectRequested> {
lane.first_proposal()
.map(|proposal| PluginBufferEditProposalRejectRequested { proposal })
}
fn render_proposal_lane(
proposals: Option<&PluginProposalLane>,
receipts: Option<&PluginProposalReceiptLog>,
focused_buffer: Option<BufferEntity>,
) -> RenderedProposalLane {
let Some(first) = proposals.and_then(|proposals| proposals.pending_buffer_edits().next())
else {
return receipts
.and_then(PluginProposalReceiptLog::latest)
.map_or_else(RenderedProposalLane::default, |receipt| {
render_proposal_receipt(receipt, focused_buffer)
});
};
let text = render_buffer_edit_proposal(first, focused_buffer);
let status = String::from("pending");
let diff_preview = render_buffer_edit_diff_preview(
first.diff_preview(),
proposal_target_label(first.target(), focused_buffer),
);
let first_proposal = Some(first.id());
let remaining = proposals.map_or(0, |proposals| proposals.len().saturating_sub(1));
let text = if remaining == 0 {
text
} else {
let count = proposals.map_or(1, PluginProposalLane::len);
format!("{count} plugin proposals: {text}; +{remaining} more")
};
RenderedProposalLane {
text,
status,
diff_preview,
first_proposal,
}
}
fn render_buffer_edit_proposal(
proposal: &PendingPluginBufferEditProposal,
focused_buffer: Option<BufferEntity>,
) -> String {
let identity = EscapedDisplayText::from_display_text(proposal.provenance().source_identity());
format!(
"#{} {} {} {} r{}: {}",
proposal.id().get(),
identity.as_str(),
proposal.provenance().capability().as_str(),
proposal_target_label(proposal.target(), focused_buffer),
proposal.provenance().base_revision().get(),
edit_shape_text(&proposal.edit_shape())
)
}
fn proposal_target_label(
target: BufferEntity,
focused_buffer: Option<BufferEntity>,
) -> &'static str {
if focused_buffer == Some(target) {
"focused-buffer"
} else {
"buffer"
}
}
fn render_proposal_receipt(
receipt: &PluginBufferEditProposalReceipt,
focused_buffer: Option<BufferEntity>,
) -> RenderedProposalLane {
let identity = EscapedDisplayText::from_display_text(receipt.source_identity());
RenderedProposalLane {
text: format!(
"#{} {} {} {} r{}: {}",
receipt.proposal().get(),
identity.as_str(),
receipt.capability().as_str(),
proposal_target_label(receipt.target(), focused_buffer),
receipt.base_revision().get(),
edit_shape_text(receipt.proposed())
),
status: render_proposal_receipt_status(receipt.outcome()),
diff_preview: String::new(),
first_proposal: None,
}
}
fn render_proposal_receipt_status(outcome: &PluginBufferEditProposalReceiptOutcome) -> String {
match outcome {
PluginBufferEditProposalReceiptOutcome::Applied {
previous_revision,
current_revision,
} => format!(
"applied r{} -> r{}",
previous_revision.revision.get(),
current_revision.revision.get()
),
PluginBufferEditProposalReceiptOutcome::Rejected { owner, reason } => {
format!(
"rejected by {}: {}",
receipt_owner_text(*owner),
receipt_rejection_text(reason)
)
}
}
}
const fn receipt_owner_text(owner: PluginBufferEditProposalReceiptOwner) -> &'static str {
match owner {
PluginBufferEditProposalReceiptOwner::ProposalLane => "proposal-lane",
PluginBufferEditProposalReceiptOwner::BufferOwner => "buffer-owner",
}
}
fn receipt_rejection_text(reason: &PluginBufferEditProposalReceiptRejectionReason) -> String {
match reason {
PluginBufferEditProposalReceiptRejectionReason::RejectedBeforeOwner => {
String::from("manual reject")
}
PluginBufferEditProposalReceiptRejectionReason::MissingTarget => {
String::from("missing target")
}
PluginBufferEditProposalReceiptRejectionReason::InvalidEdit { error } => {
format!("invalid edit: {error}")
}
PluginBufferEditProposalReceiptRejectionReason::StaleRevision { expected, actual } => {
format!("stale revision r{} -> r{}", expected.get(), actual.get())
}
}
}
fn edit_shape_text(shape: &BufferEditShape) -> String {
match shape.kind() {
BufferEditKind::SetAll => format!(
"replace-all {} bytes",
shape.replacement_byte_len().unwrap_or(0)
),
BufferEditKind::Insert => format!(
"insert {} bytes at {}",
shape.replacement_byte_len().unwrap_or(0),
shape.byte_index().unwrap_or(0)
),
BufferEditKind::Replace => shape.range().map_or_else(
|| {
format!(
"replace with {} bytes",
shape.replacement_byte_len().unwrap_or(0)
)
},
|range| {
format!(
"replace {}..{} with {} bytes",
range.start(),
range.end(),
shape.replacement_byte_len().unwrap_or(0)
)
},
),
BufferEditKind::Delete => shape.range().map_or_else(
|| String::from("delete"),
|range| format!("delete {}..{}", range.start(), range.end()),
),
}
}
fn render_buffer_edit_diff_preview(
preview: PluginBufferEditDiffPreview,
target: &'static str,
) -> String {
match preview {
PluginBufferEditDiffPreview::SetAll {
base_revision,
added_byte_len,
} => format!(
"@@ {target} r{} @@ replace all with +{} bytes",
base_revision.get(),
added_byte_len
),
PluginBufferEditDiffPreview::Insert {
base_revision,
byte_index,
added_byte_len,
} => format!(
"@@ {target} r{} @@ -0 +{} bytes at {}",
base_revision.get(),
added_byte_len,
byte_index
),
PluginBufferEditDiffPreview::Replace {
base_revision,
range,
removed_byte_len,
added_byte_len,
} => format!(
"@@ {target} r{} @@ -{} +{} bytes at {}..{}",
base_revision.get(),
diff_byte_len_text(removed_byte_len),
added_byte_len,
range.start(),
range.end()
),
PluginBufferEditDiffPreview::Delete {
base_revision,
range,
removed_byte_len,
} => format!(
"@@ {target} r{} @@ -{} +0 bytes at {}..{}",
base_revision.get(),
diff_byte_len_text(removed_byte_len),
range.start(),
range.end()
),
}
}
fn diff_byte_len_text(byte_len: Option<usize>) -> String {
byte_len.map_or_else(|| String::from("?"), |byte_len| byte_len.to_string())
}
#[cfg(test)]
mod tests {
use super::{
ChromeProposalLane, first_proposal_apply_request, first_proposal_reject_request,
render_proposal_lane,
};
use crate::{
buffer::{BufferEdit, BufferEditShape},
ecs::{
PluginProposalLane, PluginProposalReceiptLog,
components::buffer::{BufferEntity, BufferRevision},
events::edit::{
PluginBufferEditProposalApplyRequested, PluginBufferEditProposalReceipt,
PluginBufferEditProposalRejectRequested, PluginBufferEditProvenance,
PluginProposalId, RevisionGuardedBufferEditRequested,
},
},
plugin::PluginIdentity,
text_stream::TextRevision,
};
use bevy::prelude::Entity;
use std::num::NonZeroU64;
#[test]
fn proposal_lane_summary_redacts_edit_text() {
let buffer = BufferEntity(Entity::from_raw_u32(1).expect("entity"));
let mut lane = PluginProposalLane::default();
let _id = lane
.push_buffer_edit(request(buffer, "proposal-plugin", "secret payload"))
.expect("proposal should fit");
let rendered = render_proposal_lane(Some(&lane), None, Some(buffer));
assert!(
rendered
.text
.contains("#1 proposal-plugin buffer.propose_edit focused-buffer r0")
);
assert_eq!(rendered.status, "pending");
assert!(rendered.text.contains("insert 14 bytes at 0"));
assert_eq!(
rendered.diff_preview,
"@@ focused-buffer r0 @@ -0 +14 bytes at 0"
);
assert!(!rendered.text.contains("secret"));
assert!(!rendered.text.contains("payload"));
assert!(!rendered.diff_preview.contains("secret"));
assert!(!rendered.diff_preview.contains("payload"));
assert!(!rendered.text.contains('\n'));
}
#[test]
fn proposal_lane_summary_shows_pending_count() {
let buffer = BufferEntity(Entity::from_raw_u32(1).expect("entity"));
let mut lane = PluginProposalLane::default();
let _first = lane
.push_buffer_edit(request(buffer, "first", "a"))
.expect("proposal should fit");
let _second = lane
.push_buffer_edit(request(buffer, "second", "b"))
.expect("proposal should fit");
let rendered = render_proposal_lane(Some(&lane), None, None);
assert!(rendered.text.starts_with("2 plugin proposals: #1 first"));
assert_eq!(rendered.status, "pending");
assert!(
rendered
.text
.contains(" buffer r0: insert 1 bytes at 0; +1 more")
);
assert_eq!(rendered.diff_preview, "@@ buffer r0 @@ -0 +1 bytes at 0");
}
#[test]
fn proposal_lane_diff_preview_redacts_replacement_text() {
let buffer = BufferEntity(Entity::from_raw_u32(1).expect("entity"));
let mut lane = PluginProposalLane::default();
let _id = lane
.push_buffer_edit(replace_request(
buffer,
"preview",
1..3,
"secret replacement",
))
.expect("proposal should fit");
let rendered = render_proposal_lane(Some(&lane), None, Some(buffer));
assert_eq!(
rendered.diff_preview,
"@@ focused-buffer r0 @@ -2 +18 bytes at 1..3"
);
assert!(!rendered.text.contains("secret"));
assert!(!rendered.text.contains("replacement"));
assert!(!rendered.diff_preview.contains("secret"));
assert!(!rendered.diff_preview.contains("replacement"));
}
#[test]
fn proposal_lane_status_renders_latest_receipt_without_pending_proposal() {
let buffer = BufferEntity(Entity::from_raw_u32(1).expect("entity"));
let mut receipts = PluginProposalReceiptLog::default();
let receipt = applied_receipt(buffer, "done-plugin", "secret replacement");
receipts.push(receipt);
let rendered = render_proposal_lane(None, Some(&receipts), Some(buffer));
assert!(
rendered
.text
.contains("#1 done-plugin buffer.propose_edit focused-buffer r0")
);
assert_eq!(rendered.status, "applied r0 -> r1");
assert_eq!(rendered.diff_preview, "");
assert_eq!(rendered.first_proposal, None);
assert!(!rendered.text.contains("secret"));
assert!(!rendered.status.contains("secret"));
assert!(!rendered.text.contains('\n'));
}
#[test]
fn proposal_lane_controls_emit_first_pending_proposal_requests() {
let buffer = BufferEntity(Entity::from_raw_u32(1).expect("entity"));
let mut proposals = PluginProposalLane::default();
let first = proposals
.push_buffer_edit(request(buffer, "first", "a"))
.expect("proposal should fit");
let _second = proposals
.push_buffer_edit(request(buffer, "second", "b"))
.expect("proposal should fit");
let rendered = render_proposal_lane(Some(&proposals), None, Some(buffer));
let lane = ChromeProposalLane {
text: rendered.text,
status: rendered.status,
diff_preview: rendered.diff_preview,
first_proposal: rendered.first_proposal,
..Default::default()
};
assert_eq!(
first_proposal_apply_request(&lane),
Some(PluginBufferEditProposalApplyRequested { proposal: first })
);
assert_eq!(
first_proposal_reject_request(&lane),
Some(PluginBufferEditProposalRejectRequested { proposal: first })
);
}
#[test]
fn proposal_lane_controls_noop_without_pending_proposal() {
let lane = ChromeProposalLane::default();
assert_eq!(first_proposal_apply_request(&lane), None);
assert_eq!(first_proposal_reject_request(&lane), None);
}
fn request(
target: BufferEntity,
source_identity: &str,
text: &str,
) -> RevisionGuardedBufferEditRequested {
RevisionGuardedBufferEditRequested {
target,
provenance: PluginBufferEditProvenance::buffer_propose_edit(
plugin_identity(source_identity),
TextRevision::ZERO,
),
edit: BufferEdit::insert(0, text),
}
}
fn replace_request(
target: BufferEntity,
source_identity: &str,
range: std::ops::Range<usize>,
text: &str,
) -> RevisionGuardedBufferEditRequested {
RevisionGuardedBufferEditRequested {
target,
provenance: PluginBufferEditProvenance::buffer_propose_edit(
plugin_identity(source_identity),
TextRevision::ZERO,
),
edit: BufferEdit::replace_unchecked(range, text),
}
}
fn applied_receipt(
target: BufferEntity,
source_identity: &str,
text: &str,
) -> PluginBufferEditProposalReceipt {
let proposal = PluginProposalId::from_nonzero(NonZeroU64::MIN);
let provenance = PluginBufferEditProvenance::buffer_propose_edit(
plugin_identity(source_identity),
TextRevision::ZERO,
)
.with_proposal_id(proposal);
let edit = BufferEdit::insert(0, text);
PluginBufferEditProposalReceipt::owner_applied(
&provenance,
target,
BufferEditShape::from_edit(&edit),
BufferRevision {
revision: TextRevision::ZERO,
},
BufferRevision {
revision: TextRevision::from(1),
},
)
.expect("id-bearing provenance should produce a receipt")
}
fn plugin_identity(identity: &str) -> PluginIdentity {
PluginIdentity::try_new(identity).expect("test identity should be valid")
}
}