diff --git a/document/format/src/persist.rs b/document/format/src/persist.rs index 8f736f75ea1..9f79763497c 100644 --- a/document/format/src/persist.rs +++ b/document/format/src/persist.rs @@ -458,6 +458,23 @@ impl Gdd { Ok(()) } + /// Move the head back to an ancestor, keeping the later line as a branch. A non-retiring peer asks the retirer. + #[cfg(feature = "network")] + pub fn move_head(&mut self, rev: Rev) -> Result<(), Error> { + if self.retires_locally() { + let (moved, touched) = self.session.move_head_to(rev)?; + self.remote_changes.touched.extend(touched); + self.persist_registry_snapshot()?; + self.persist_session_state()?; + if let Some(replica) = &mut self.network { + replica.broadcast_head_move(moved)?; + } + } else if let Some(replica) = &mut self.network { + replica.request_move(rev)?; + } + Ok(()) + } + /// Redo a dropped step as a copy on top of the line. A non-retiring peer asks the retirer. #[cfg(feature = "network")] pub fn redo_retired_step(&mut self, rev: Rev) -> Result<(), Error> { diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 065e65b70b7..d4b1704a58a 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -307,6 +307,7 @@ impl Dispatcher { let layout = &self.message_handlers.portfolio_message_handler.workspace.panel_layout; menu_bar_message_handler.data_panel_open = layout.is_panel_present(PanelType::Data); menu_bar_message_handler.session_panel_open = layout.is_panel_present(PanelType::Session); + menu_bar_message_handler.history_panel_open = layout.is_panel_present(PanelType::History); menu_bar_message_handler.layers_panel_open = layout.is_panel_present(PanelType::Layers); menu_bar_message_handler.inspector_open = layout.is_panel_present(PanelType::Inspector); menu_bar_message_handler.message_logging_verbosity = self.message_handlers.debug_message_handler.message_logging_verbosity; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 65eec234d60..f7bb6a0aff4 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -196,6 +196,9 @@ pub enum FrontendMessage { UpdateDocumentCursors { cursors: Vec, }, + UpdateHistoryPanel { + state: crate::messages::portfolio::history::utility_types::HistoryPanelState, + }, UpdateNodeGraphCursors { cursors: Vec, }, diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index b8d316a2197..be715e2bdb5 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -25,6 +25,7 @@ pub struct MenuBarMessageHandler { pub make_path_editable_is_allowed: bool, pub data_panel_open: bool, pub session_panel_open: bool, + pub history_panel_open: bool, pub layers_panel_open: bool, pub inspector_open: bool, pub focus_document: bool, @@ -693,6 +694,11 @@ impl LayoutHolder for MenuBarMessageHandler { .icon(if self.session_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" }) .on_commit(|_| WorkspaceMessage::ToggleSessionPanelOpen.into()) .disabled(self.focus_document), + MenuListEntry::new("History") + .label("History") + .icon(if self.history_panel_open { "CheckboxChecked" } else { "CheckboxUnchecked" }) + .on_commit(|_| WorkspaceMessage::ToggleHistoryPanelOpen.into()) + .disabled(self.focus_document), ], ]) .widget_instance(), diff --git a/editor/src/messages/portfolio/document/document_history.rs b/editor/src/messages/portfolio/document/document_history.rs index bf5f7b61c7b..1af6ffda538 100644 --- a/editor/src/messages/portfolio/document/document_history.rs +++ b/editor/src/messages/portfolio/document/document_history.rs @@ -231,8 +231,29 @@ impl DocumentHistory { } pub fn redo_retired_step(&mut self, rev: document_graph_storage::Rev) -> bool { - let Some(storage) = self.storage.as_mut() else { return false }; - storage.redo_retired_step(rev).inspect_err(|error| log::error!("Redoing the dropped step failed: {error}")).is_ok() + self.change_line(|storage| storage.redo_retired_step(rev), "Redoing the dropped step failed") + } + + pub fn drop_step(&mut self, rev: document_graph_storage::Rev) -> bool { + self.change_line(|storage| storage.drop_step(rev), "Removing the step failed") + } + + pub fn move_head_to(&mut self, rev: document_graph_storage::Rev) -> bool { + self.change_line(|storage| storage.move_head(rev), "Moving the head failed") + } + + fn change_line(&mut self, change: impl FnOnce(&mut document_format::GddV1) -> Result<(), document_format::Error>, failure: &str) -> bool { + let Some(storage) = self.storage.as_mut().filter(|storage| storage.can_change_line()) else { + return false; + }; + change(storage).inspect_err(|error| log::error!("{failure}: {error}")).is_ok() + } + + /// Forgets the legacy snapshots once the head has moved off their line. + pub fn clear_legacy(&mut self) { + self.legacy_undo_stack.clear(); + self.legacy_redo_stack.clear(); + self.undo_notes.clear(); } pub fn retire_storage_interaction(&mut self) { diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 53d3843d4bf..5270dac6bfb 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -56,6 +56,17 @@ pub enum DocumentMessage { DeselectAllLayers, DocumentHistoryBackward, DocumentHistoryForward, + /// Moves the shared head to an earlier interaction and keeps the later steps as a branch. + HistoryMoveHead { + rev: document_graph_storage::Rev, + }, + /// Drops a retired interaction and mints the later ones again on its parent. + HistoryRemoveStep { + rev: document_graph_storage::Rev, + }, + HistoryRestoreStep { + rev: document_graph_storage::Rev, + }, DocumentStructureChanged, DrawArtboardOverlays { context: OverlayContext, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 43a1089369a..65958a61c75 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -473,6 +473,27 @@ impl MessageHandler> for DocumentMes self.undo_with_history(document_id, viewport, preferences.validate_storage_round_trip, responses) } DocumentMessage::DocumentHistoryForward => self.redo_with_history(document_id, viewport, preferences.validate_storage_round_trip, responses), + DocumentMessage::HistoryMoveHead { rev } => self.apply_history_action( + document_id, + |history| history.move_head_to(rev), + &resource_storage.resources_mut(), + preferences.validate_storage_round_trip, + responses, + ), + DocumentMessage::HistoryRemoveStep { rev } => self.apply_history_action( + document_id, + |history| history.drop_step(rev), + &resource_storage.resources_mut(), + preferences.validate_storage_round_trip, + responses, + ), + DocumentMessage::HistoryRestoreStep { rev } => self.apply_history_action( + document_id, + |history| history.redo_retired_step(rev), + &resource_storage.resources_mut(), + preferences.validate_storage_round_trip, + responses, + ), DocumentMessage::DocumentStructureChanged => { if layers_panel_open { self.network_interface.load_structure(); @@ -2997,6 +3018,32 @@ impl DocumentMessageHandler { self.drive_storage_undo_redo(document_id, legacy_applied, true, validate, responses); } + /// Performs a History panel action in storage and drops the legacy snapshots, which are off the head's line. + fn apply_history_action( + &mut self, + document_id: DocumentId, + action: impl FnOnce(&mut DocumentHistory) -> bool, + byte_store: &dyn ResourceStorage, + validate: bool, + responses: &mut VecDeque, + ) { + if self.network_interface.transaction_status() != TransactionStatus::Finished { + return; + } + responses.add(EventMessage::ToolAbort); + self.stage_edits_outside_transaction(byte_store, validate); + self.history.retire_storage_interaction(); + let stored_name = self.stored_name().map(str::to_string); + if !action(&mut self.history) { + return; + } + self.history.clear_legacy(); + self.follow_storage_changes(stored_name, responses); + responses.add(PortfolioMessage::AutoSaveDocument { document_id }); + responses.add(OverlaysMessage::Draw); + responses.add(EventMessage::SelectionChanged); + } + /// Reconciles the interface with storage. The tab takes the stored name only if it changed from `name_before`. fn follow_storage_changes(&mut self, name_before: Option, responses: &mut VecDeque) { if let Some(gdd) = self.history.storage_mut() { diff --git a/editor/src/messages/portfolio/history/history_message.rs b/editor/src/messages/portfolio/history/history_message.rs new file mode 100644 index 00000000000..1b255f756e9 --- /dev/null +++ b/editor/src/messages/portfolio/history/history_message.rs @@ -0,0 +1,16 @@ +use crate::messages::prelude::*; + +#[impl_message(Message, PortfolioMessage, History)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum HistoryMessage { + Refresh, + Tick, + Expand { id: String, expanded: bool }, + LoadMore, + GoBack { id: String }, + RemoveStep { id: String }, + BringBack { id: String }, + Follow { id: Option }, + Rename { id: String, label: String }, + Tag { id: String, tag: String, on: bool }, +} diff --git a/editor/src/messages/portfolio/history/history_message_handler.rs b/editor/src/messages/portfolio/history/history_message_handler.rs new file mode 100644 index 00000000000..bd85a62092d --- /dev/null +++ b/editor/src/messages/portfolio/history/history_message_handler.rs @@ -0,0 +1,508 @@ +use super::utility_types::{HistoryBranch, HistoryDeltaRow, HistoryPanelState, HistoryProgressRow, HistoryRow}; +use crate::messages::portfolio::document::DocumentMessageHandler; +use crate::messages::portfolio::sync::identity::{anonymous_name, user_color}; +use crate::messages::portfolio::sync::now_ms; +use crate::messages::prelude::*; +use document_format::GddV1; +use document_graph_storage::{Delta, HistoryMetadata, HotOpId, PeerId, RegistryDelta, Rev, Session, Subject, UserId, rev_attr}; +use peer_transport::RemotePeer; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; + +#[derive(ExtractField)] +pub struct HistoryMessageContext<'a> { + pub documents: &'a mut HashMap, + pub active_document_id: Option, + pub panel_open: bool, +} + +/// Sends the History panel the active document's history and turns its actions into head moves. +#[derive(Debug, Default, ExtractField)] +pub struct HistoryMessageHandler { + ticking: bool, + expanded: HashSet, + limit: usize, + following: Option, + last_sent: Option, +} + +/// How many interactions each request adds. +const PAGE: usize = 100; + +#[message_handler_data] +impl MessageHandler> for HistoryMessageHandler { + fn process_message(&mut self, message: HistoryMessage, responses: &mut VecDeque, mut context: HistoryMessageContext) { + match message { + HistoryMessage::Refresh => { + if !self.ticking { + self.ticking = true; + responses.add(BroadcastMessage::SubscribeEvent { + on: EventMessage::AnimationFrame, + send: Box::new(HistoryMessage::Tick.into()), + }); + } + self.send(&context, responses, true); + } + HistoryMessage::Tick => { + if context.panel_open { + self.send(&context, responses, false); + } + } + HistoryMessage::Expand { id, expanded } => { + if let Some(rev) = parse_rev(&id) { + match expanded { + true => self.expanded.insert(rev), + false => self.expanded.remove(&rev), + }; + } + self.send(&context, responses, true); + } + HistoryMessage::LoadMore => { + self.limit = self.limit.max(PAGE) + PAGE; + self.send(&context, responses, true); + } + HistoryMessage::Follow { id } => { + self.following = id.as_deref().and_then(parse_rev); + self.send(&context, responses, true); + } + HistoryMessage::GoBack { id } => { + let Some((gdd, rev)) = active_storage(&context).zip(parse_rev(&id)) else { return }; + match place(gdd.session(), rev) { + Some(Place::Undone(steps)) => (0..steps).for_each(|_| responses.add(DocumentMessage::Redo)), + Some(Place::Line(0)) => {} + // A session moves the head for everyone at once. Alone, undo each interaction to keep undo in step + Some(Place::Line(steps)) => match gdd.role().is_some() { + true => responses.add(DocumentMessage::HistoryMoveHead { rev }), + false => (0..steps).for_each(|_| responses.add(DocumentMessage::Undo)), + }, + None => log::warn!("The History panel asked to go to {rev:?}, which is not on the head's line"), + } + } + HistoryMessage::RemoveStep { id } => { + if let Some(rev) = parse_rev(&id) { + responses.add(DocumentMessage::HistoryRemoveStep { rev }); + } + } + HistoryMessage::BringBack { id } => { + let Some((gdd, rev)) = active_storage(&context).zip(parse_rev(&id)) else { return }; + match place(gdd.session(), rev) { + Some(Place::Undone(steps)) => (0..steps).for_each(|_| responses.add(DocumentMessage::Redo)), + Some(Place::Line(_)) => {} + None => responses.add(DocumentMessage::HistoryRestoreStep { rev }), + } + } + HistoryMessage::Rename { id, label } => { + let Some((gdd, rev)) = active_storage_mut(&mut context).zip(parse_rev(&id)) else { return }; + if let Err(error) = gdd.record(Subject::Rev(rev), rev_attr::LABEL, serde_json::Value::from(label.trim()), now_ms()) { + log::warn!("Naming the step failed: {error}"); + } + self.send(&context, responses, true); + } + HistoryMessage::Tag { id, tag, on } => { + let tag = tag.trim(); + if tag.is_empty() { + return; + } + let Some((gdd, rev)) = active_storage_mut(&mut context).zip(parse_rev(&id)) else { return }; + if let Err(error) = gdd.record(Subject::Rev(rev), &format!("{}{tag}", rev_attr::TAG_PREFIX), serde_json::Value::Bool(on), now_ms()) { + log::warn!("Tagging the step failed: {error}"); + } + self.send(&context, responses, true); + } + } + } + + advertise_actions!(HistoryMessageDiscriminant;); +} + +impl HistoryMessageHandler { + fn send(&mut self, context: &HistoryMessageContext, responses: &mut VecDeque, force: bool) { + let Some(gdd) = active_storage(context) else { + if force || self.last_sent.is_some() { + self.last_sent = None; + responses.add(FrontendMessage::UpdateHistoryPanel { state: HistoryPanelState::default() }); + } + return; + }; + if self.following.is_some_and(|tip| gdd.session().delta(tip).is_none()) { + self.following = None; + } + let limit = self.limit.max(PAGE); + let fingerprint = fingerprint(context.active_document_id, gdd, limit, &self.expanded, self.following); + if !force && self.last_sent == Some(fingerprint) { + return; + } + self.last_sent = Some(fingerprint); + let state = panel_state(gdd, limit, &self.expanded, self.following); + responses.add(FrontendMessage::UpdateHistoryPanel { state }); + } +} + +fn active_storage<'a>(context: &'a HistoryMessageContext) -> Option<&'a GddV1> { + context.active_document_id.and_then(|id| context.documents.get(&id)).and_then(|document| document.storage()) +} + +fn active_storage_mut<'a>(context: &'a mut HistoryMessageContext) -> Option<&'a mut GddV1> { + context.active_document_id.and_then(|id| context.documents.get_mut(&id)).and_then(|document| document.storage_mut()) +} + +fn parse_rev(id: &str) -> Option { + id.parse::().ok().and_then(Rev::new) +} + +/// Where an interaction sits relative to the head. +enum Place { + /// On the head's line, this many interactions back. + Line(usize), + /// Undone above the head, this many redos forward. + Undone(usize), +} + +fn place(session: &Session, rev: Rev) -> Option { + let head = session.head_rev(); + let (kept, _) = line(session, head, None, usize::MAX); + if let Some(steps) = interactions(&kept).iter().position(|group| group[0].id == rev) { + return Some(Place::Line(steps)); + } + // The stack's first entry is the farthest undone, so its line holds every undone step + let checkpoint = *session.redo_stack().first()?; + let (undone, _) = line(session, Some(checkpoint), head, usize::MAX); + let groups = interactions(&undone); + let index = groups.iter().position(|group| group[0].id == rev)?; + Some(Place::Undone(groups.len() - index)) +} + +/// A hash of the panel's inputs, cheap enough for every frame. +fn fingerprint(document_id: Option, gdd: &GddV1, limit: usize, expanded: &HashSet, following: Option) -> u64 { + let session = gdd.session(); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + document_id.hash(&mut hasher); + session.history_len().hash(&mut hasher); + session.head_rev().hash(&mut hasher); + session.redo_stack().hash(&mut hasher); + session.hot_log().len().hash(&mut hasher); + gdd.role().is_some().hash(&mut hasher); + for remote in gdd.peers() { + remote.user.hash(&mut hasher); + remote.name.hash(&mut hasher); + } + for fact in gdd.metadata().facts() { + fact.subject.hash(&mut hasher); + fact.key.hash(&mut hasher); + fact.stamp.hash(&mut hasher); + } + limit.hash(&mut hasher); + expanded.len().hash(&mut hasher); + following.hash(&mut hasher); + hasher.finish() +} + +fn panel_state(gdd: &GddV1, limit: usize, expanded: &HashSet, following: Option) -> HistoryPanelState { + let session = gdd.session(); + let people = People { + session, + metadata: gdd.metadata(), + peers: gdd.peers().collect(), + }; + + let mut children: HashMap> = HashMap::new(); + for delta in session.history() { + for parent in delta.all_parents() { + children.entry(parent).or_default().push(delta.id); + } + } + + let head = session.head_rev(); + let (head_line, _) = line(session, head, None, usize::MAX); + let head_line_revs: HashSet = head_line.iter().map(|delta| delta.id).collect(); + + let undone = match (following, session.redo_stack().first()) { + (None, Some(&checkpoint)) => line(session, Some(checkpoint), head, usize::MAX).0, + _ => Vec::new(), + }; + let (shown, more) = line(session, following.or(head), None, limit); + let on_line: HashSet = undone.iter().chain(&shown).map(|delta| delta.id).collect(); + + let view = View { + children: &children, + on_line: &on_line, + head_line: &head_line_revs, + head, + people: &people, + expanded, + session, + metadata: gdd.metadata(), + }; + let undone_rows = interactions(&undone).into_iter().map(|group| view.row(&group, true)); + let shown_rows = interactions(&shown).into_iter().map(|group| view.row(&group, false)); + let rows = undone_rows.chain(shown_rows).collect(); + + // Open interactions first, then closed ones waiting to retire, newest first + let hot_log = session.hot_log(); + let closed = session.closed_interactions(); + let in_closed: HashSet = closed.iter().flat_map(|interaction| interaction.ops.iter().copied()).collect(); + let mut open_by_peer: Vec<(PeerId, Vec<&RegistryDelta>)> = Vec::new(); + for hot_op in hot_log.iter().filter(|hot_op| !in_closed.contains(&hot_op.id())) { + match open_by_peer.iter_mut().find(|(peer, _)| *peer == hot_op.timestamp.peer) { + Some((_, ops)) => ops.push(&hot_op.op), + None => open_by_peer.push((hot_op.timestamp.peer, vec![&hot_op.op])), + } + } + let mut progress: Vec = open_by_peer.into_iter().map(|(peer, ops)| progress_row(&people, peer, &ops, true)).collect(); + for interaction in closed.iter().rev() { + let ids: HashSet = interaction.ops.iter().copied().collect(); + let ops: Vec<&RegistryDelta> = hot_log.iter().filter(|hot_op| ids.contains(&hot_op.id())).map(|hot_op| &hot_op.op).collect(); + progress.push(progress_row(&people, interaction.author, &ops, false)); + } + + HistoryPanelState { + rows, + progress, + more, + following: following.map(|tip| tip.to_string()), + session: gdd.role().is_some(), + } +} + +/// The deltas from `from` back along first parents to `until` (exclusive) or the root, newest first. Stops after +/// `limit` interaction ends, and the flag says whether it did. +fn line(session: &Session, from: Option, until: Option, limit: usize) -> (Vec, bool) { + let mut deltas = Vec::new(); + let mut ends = 0; + let mut current = from; + while let Some(rev) = current + && Some(rev) != until + { + let Some(delta) = session.delta(rev) else { break }; + if delta.is_interaction_end() { + if ends == limit { + return (deltas, true); + } + ends += 1; + } + deltas.push(delta.clone()); + current = delta.parent; + } + (deltas, false) +} + +/// Groups a newest-first line into interactions. Newest deltas without an end, retired mid-interaction, form their own. +fn interactions(deltas: &[Delta]) -> Vec> { + let mut groups: Vec> = Vec::new(); + for delta in deltas { + match groups.last_mut() { + Some(group) if !delta.is_interaction_end() => group.push(delta), + _ => groups.push(vec![delta]), + } + } + groups +} + +struct View<'a> { + children: &'a HashMap>, + on_line: &'a HashSet, + head_line: &'a HashSet, + head: Option, + people: &'a People<'a>, + expanded: &'a HashSet, + session: &'a Session, + metadata: &'a HistoryMetadata, +} + +impl View<'_> { + fn row(&self, group: &[&Delta], undone: bool) -> HistoryRow { + let closing = group[0]; + let person = self.people.person(closing.author); + let branches = group + .iter() + .flat_map(|delta| self.children.get(&delta.id).into_iter().flatten()) + .filter(|child| !self.on_line.contains(child)) + .map(|&child| self.branch(child)) + .collect(); + let is_expanded = self.expanded.contains(&closing.id); + let details = match is_expanded { + true => group + .iter() + .map(|delta| { + let author = self.people.person(delta.author); + HistoryDeltaRow { + id: delta.id.to_string(), + label: describe(&delta.kind), + author: author.name, + color: author.color, + time: delta.retired_at().map(|ms| ms as f64), + } + }) + .collect(), + false => Vec::new(), + }; + let given = self.metadata.rev_label(closing.id); + HistoryRow { + id: closing.id.to_string(), + label: given.map(str::to_string).unwrap_or_else(|| summarize(group.iter().map(|delta| &delta.kind))), + named: given.is_some(), + tags: self.metadata.rev_tags(closing.id).into_iter().map(str::to_string).collect(), + author: person.name, + anonymous: person.anonymous, + mine: person.mine, + color: person.color, + time: group.iter().find_map(|delta| delta.retired_at()).map(|ms| ms as f64), + deltas: group.len(), + branches, + undone, + abandoned: !undone && !self.head_line.contains(&closing.id), + head: Some(closing.id) == self.head, + expanded: is_expanded, + details, + } + } + + fn branch(&self, first: Rev) -> HistoryBranch { + let mut tip = first; + while let Some(&next) = self.children.get(&tip).and_then(|children| children.first()) { + tip = next; + } + let (newest, _) = line(self.session, Some(tip), None, 1); + let label = self + .metadata + .rev_label(tip) + .map(str::to_string) + .unwrap_or_else(|| interactions(&newest).first().map(|group| summarize(group.iter().map(|delta| &delta.kind))).unwrap_or_default()); + HistoryBranch { id: tip.to_string(), label } + } +} + +fn progress_row(people: &People, peer: PeerId, ops: &[&RegistryDelta], open: bool) -> HistoryProgressRow { + let person = people.person(peer); + HistoryProgressRow { + label: summarize(ops.iter().copied()), + author: person.name, + anonymous: person.anonymous, + mine: person.mine, + color: person.color, + ops: ops.len(), + open, + } +} + +/// One line for a run of changes, most frequent kind first. Peer registrations and markers are named only when alone. +fn summarize<'a>(kinds: impl IntoIterator) -> String { + let is_bookkeeping = |kind: &RegistryDelta| matches!(kind, RegistryDelta::RegisterPeer { .. } | RegistryDelta::Meta); + let mut kinds: Vec<&RegistryDelta> = kinds.into_iter().collect(); + if kinds.iter().any(|kind| !is_bookkeeping(kind)) { + kinds.retain(|kind| !is_bookkeeping(kind)); + } + let mut counts: Vec<(String, usize)> = Vec::new(); + for kind in kinds { + let label = describe(kind); + match counts.iter_mut().find(|(known, _)| *known == label) { + Some((_, count)) => *count += 1, + None => counts.push((label, 1)), + } + } + counts.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + let mut parts: Vec = counts + .iter() + .take(2) + .map(|(label, count)| match count { + 1 => label.clone(), + count => format!("{label} ×{count}"), + }) + .collect(); + if counts.len() > 2 { + parts.push(format!("{} more", counts.len() - 2)); + } + parts.join(", ") +} + +fn describe(kind: &RegistryDelta) -> String { + match kind { + RegistryDelta::AddNode { .. } => "Added a node".into(), + RegistryDelta::RemoveNode { .. } => "Removed a node".into(), + RegistryDelta::ChangeNodeInput { .. } => "Changed an input".into(), + RegistryDelta::SetNodeInputs { .. } => "Set a node's inputs".into(), + RegistryDelta::SetNodeImplementation { .. } => "Changed what a node does".into(), + RegistryDelta::ChangeNodeAttribute { delta, .. } => format!("Set node {}", delta.key), + RegistryDelta::ChangeNodeInputAttribute { delta, .. } => format!("Set input {}", delta.key), + RegistryDelta::SetNetworkExport { .. } => "Changed an export".into(), + RegistryDelta::ChangeNetworkAttribute { delta, .. } => format!("Set network {}", delta.key), + RegistryDelta::AddNetwork { .. } => "Added a network".into(), + RegistryDelta::RemoveNetwork { .. } => "Removed a network".into(), + RegistryDelta::AddResource { .. } => "Added a resource".into(), + RegistryDelta::SetResourceHash { .. } => "Updated a resource".into(), + RegistryDelta::RemoveResource { .. } => "Removed a resource".into(), + RegistryDelta::AddSource { .. } | RegistryDelta::RemoveSource { .. } => "Changed where a resource comes from".into(), + RegistryDelta::RegisterPeer { .. } => "Joined the document".into(), + RegistryDelta::ChangeDocumentAttribute { delta } => format!("Set document {}", delta.key), + RegistryDelta::Merge { .. } => "Joined two lines of work".into(), + RegistryDelta::Meta => "Marked a step".into(), + RegistryDelta::Other(_) => "Changed something".into(), + } +} + +/// Names and colours authors by user id. The document's record wins, then the announced name, then the stand-in. +struct People<'a> { + session: &'a Session, + metadata: &'a HistoryMetadata, + peers: Vec<&'a RemotePeer>, +} + +struct Person { + name: String, + anonymous: bool, + mine: bool, + color: String, +} + +impl People<'_> { + fn person(&self, peer: PeerId) -> Person { + // A peer that never registered is its own user + let user = self.session.user_of(peer).unwrap_or(UserId(peer.0)); + let recorded = self.metadata.user_name(user).map(str::to_string); + let announced = || self.peers.iter().find(|remote| remote.user == user && !remote.name.is_empty()).map(|remote| remote.name.clone()); + let (name, anonymous) = match recorded.or_else(announced) { + Some(name) => (name, false), + None => (anonymous_name(user), true), + }; + Person { + name, + anonymous, + mine: self.session.is_mine(peer), + color: user_color(user), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Undoing pushes each step's head, so undone steps must be read from the first entry. + #[test] + fn every_undone_step_stays_on_the_panel() { + use document_container::AnyContainer; + use document_container::backends::memory::MemoryBackend; + use document_format::GddV1Layout; + use document_graph_storage::{Network, NetworkId}; + + let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), UserId(1), 1, "editor".into(), "stdlib".into()).expect("create"); + for id in 1..=3 { + let network = Network::default(); + gdd.stage_ops([RegistryDelta::AddNetwork { id: NetworkId(id), network }]).expect("stage"); + gdd.retire_pending_interaction().expect("retire"); + } + gdd.undo().expect("undo"); + gdd.undo().expect("undo"); + + assert_eq!(panel_state(&gdd, PAGE, &HashSet::new(), None).rows.len(), 3); + } + + #[test] + fn a_run_of_only_markers_is_named_by_them() { + let register = RegistryDelta::RegisterPeer { peer: PeerId(1), user: UserId(1) }; + let merge = RegistryDelta::Merge { extra_parents: Vec::new() }; + assert_eq!(summarize([&RegistryDelta::Meta, &RegistryDelta::Meta]), "Marked a step ×2"); + assert_eq!(summarize([®ister, &RegistryDelta::Meta]), "Joined the document, Marked a step"); + assert_eq!(summarize([&RegistryDelta::Meta, &merge, ®ister]), "Joined two lines of work"); + } +} diff --git a/editor/src/messages/portfolio/history/mod.rs b/editor/src/messages/portfolio/history/mod.rs new file mode 100644 index 00000000000..c9086521970 --- /dev/null +++ b/editor/src/messages/portfolio/history/mod.rs @@ -0,0 +1,8 @@ +mod history_message; +mod history_message_handler; +pub mod utility_types; + +#[doc(inline)] +pub use history_message::{HistoryMessage, HistoryMessageDiscriminant}; +#[doc(inline)] +pub use history_message_handler::{HistoryMessageContext, HistoryMessageHandler}; diff --git a/editor/src/messages/portfolio/history/utility_types.rs b/editor/src/messages/portfolio/history/utility_types.rs new file mode 100644 index 00000000000..56c59a91623 --- /dev/null +++ b/editor/src/messages/portfolio/history/utility_types.rs @@ -0,0 +1,72 @@ +//! What the History panel shows, as the frontend receives it. + +/// The panel's content, newest first. The head's line with undone interactions above it, or a followed branch's line. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct HistoryPanelState { + pub rows: Vec, + /// Work not yet in history. One row per person's open work and per closed interaction waiting to retire. + pub progress: Vec, + pub more: bool, + pub following: Option, + /// In a session, a head move affects everyone. + pub session: bool, +} + +/// One interaction, a run of deltas that undo takes back together. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct HistoryRow { + /// The rev closing the interaction, in decimal. + pub id: String, + pub label: String, + pub named: bool, + pub tags: Vec, + pub author: String, + /// `author` is a stand-in. + pub anonymous: bool, + pub mine: bool, + pub color: String, + /// Milliseconds since the Unix epoch, if the retirer recorded it. + pub time: Option, + pub deltas: usize, + /// Branches off the shown line, each by its tip. + pub branches: Vec, + pub undone: bool, + /// On a followed branch, off the head's line. + pub abandoned: bool, + pub head: bool, + pub expanded: bool, + pub details: Vec, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct HistoryBranch { + pub id: String, + pub label: String, +} + +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct HistoryDeltaRow { + pub id: String, + pub label: String, + pub author: String, + pub color: String, + pub time: Option, +} + +/// A person with unretired work in the room's hot log. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct HistoryProgressRow { + pub label: String, + pub author: String, + pub anonymous: bool, + pub mine: bool, + pub color: String, + pub ops: usize, + /// Still being made, not closed and waiting. + pub open: bool, +} diff --git a/editor/src/messages/portfolio/mod.rs b/editor/src/messages/portfolio/mod.rs index 86bac518d72..3cc4b542610 100644 --- a/editor/src/messages/portfolio/mod.rs +++ b/editor/src/messages/portfolio/mod.rs @@ -7,6 +7,7 @@ pub mod document_migration; pub mod document_storage_io; pub mod failed_documents; pub mod fonts; +pub mod history; pub mod ingest; pub mod persistent_state; pub mod sync; diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index ddfbc186cd6..b71e18d41b0 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -1,4 +1,5 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; +use super::history::HistoryMessage; use super::persistent_state::PersistentStateMessage; use super::sync::SyncMessage; use crate::messages::frontend::utility_types::{ExportBounds, FileType, PersistedState}; @@ -22,6 +23,8 @@ pub enum PortfolioMessage { #[child] PersistentState(PersistentStateMessage), #[child] + History(HistoryMessage), + #[child] Sync(SyncMessage), #[child] Workspace(WorkspaceMessage), diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 6cd9e4a1e51..2d22b68a759 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -60,6 +60,7 @@ pub struct PortfolioMessageHandler { persistent_state: PersistentStateMessageHandler, pub fonts: FontsMessageHandler, ingest: IngestMessageHandler, + history: HistoryMessageHandler, sync: SyncMessageHandler, pub executor: NodeGraphExecutor, pub selection_mode: SelectionMode, @@ -138,6 +139,14 @@ impl MessageHandler> for Portfolio }; self.ingest.process_message(message, responses, context); } + PortfolioMessage::History(message) => { + let context = HistoryMessageContext { + documents: &mut self.documents, + active_document_id: self.active_document_id, + panel_open: self.workspace.panel_layout.is_panel_visible(PanelType::History) && !self.workspace.panel_layout.focus_document, + }; + self.history.process_message(message, responses, context); + } PortfolioMessage::Sync(message) => { let context = SyncMessageContext { documents: &mut self.documents, @@ -1184,6 +1193,7 @@ impl PortfolioMessageHandler { persistent_state: Default::default(), fonts: Default::default(), ingest: Default::default(), + history: Default::default(), sync: Default::default(), executor, selection_mode: Default::default(), diff --git a/editor/src/messages/portfolio/sync/mod.rs b/editor/src/messages/portfolio/sync/mod.rs index 446a48d7fbe..9ae8d5a48ca 100644 --- a/editor/src/messages/portfolio/sync/mod.rs +++ b/editor/src/messages/portfolio/sync/mod.rs @@ -4,5 +4,6 @@ mod sync_message_handler; #[doc(inline)] pub use sync_message::{SyncMessage, SyncMessageDiscriminant}; +pub(crate) use sync_message_handler::now_ms; #[doc(inline)] pub use sync_message_handler::{SyncMessageContext, SyncMessageHandler}; diff --git a/editor/src/messages/portfolio/sync/sync_message_handler.rs b/editor/src/messages/portfolio/sync/sync_message_handler.rs index ada9f53ae49..87e59886376 100644 --- a/editor/src/messages/portfolio/sync/sync_message_handler.rs +++ b/editor/src/messages/portfolio/sync/sync_message_handler.rs @@ -414,7 +414,10 @@ impl SyncMessageHandler { refresh_session_views(responses); } Event::PeerLeft { .. } | Event::ProfileChanged { .. } => refresh_session_views(responses), - Event::MetadataChanged => refresh_session_views(responses), + Event::MetadataChanged => { + refresh_session_views(responses); + responses.add(HistoryMessage::Refresh); + } Event::CursorMoved { .. } => {} } } @@ -514,7 +517,7 @@ fn load_resource_future(document_id: DocumentId, to: TransportPeerId, hash: Reso future.into() } -fn now_ms() -> f64 { +pub(crate) fn now_ms() -> f64 { #[cfg(target_arch = "wasm32")] { js_sys::Date::now() diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index cc383d53754..df1bcc4183c 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -13,6 +13,7 @@ pub enum PanelType { Inspector, Data, Session, + History, } impl From for PanelType { @@ -24,6 +25,7 @@ impl From for PanelType { "Inspector" | "Properties" => PanelType::Inspector, "Data" => PanelType::Data, "Session" => PanelType::Session, + "History" => PanelType::History, _ => panic!("Unknown panel type: {value}"), } } @@ -31,7 +33,7 @@ impl From for PanelType { impl PanelType { pub fn non_document_panels() -> &'static [PanelType] { - &[PanelType::Layers, PanelType::Inspector, PanelType::Data, PanelType::Session] + &[PanelType::Layers, PanelType::Inspector, PanelType::Data, PanelType::Session, PanelType::History] } } @@ -309,10 +311,11 @@ impl WorkspacePanelLayout { /// - Data: below the document in the left column (root child 0) /// - Inspector: top of the right column (root child 1) /// - Layers: bottom of the right column (root child 1) - /// - Session: a tab in the Inspector's group, otherwise placed like the Inspector + /// - Session, History: a tab beside the Inspector or Layers, otherwise placed like that panel fn restore_panel_to_default_position(&mut self, panel_type: PanelType) { let beside = match panel_type { PanelType::Session => Some(PanelType::Inspector), + PanelType::History => Some(PanelType::Layers), _ => None, }; if let Some(beside) = beside @@ -339,7 +342,7 @@ impl WorkspacePanelLayout { let (root_child_index, insert_at_end) = match panel_type { PanelType::Data => (0, true), // Left column, after document PanelType::Inspector | PanelType::Session => (1, false), // Right column, at top - PanelType::Layers => (1, true), // Right column, at bottom + PanelType::Layers | PanelType::History => (1, true), // Right column, at bottom _ => (1, true), }; diff --git a/editor/src/messages/portfolio/workspace/workspace_message.rs b/editor/src/messages/portfolio/workspace/workspace_message.rs index db8c2d3681c..c3b1e477a99 100644 --- a/editor/src/messages/portfolio/workspace/workspace_message.rs +++ b/editor/src/messages/portfolio/workspace/workspace_message.rs @@ -35,6 +35,7 @@ pub enum WorkspaceMessage { }, ToggleDataPanelOpen, ToggleSessionPanelOpen, + ToggleHistoryPanelOpen, ToggleInspectorOpen, ToggleLayersPanelOpen, UpdatePanelsLayout, diff --git a/editor/src/messages/portfolio/workspace/workspace_message_handler.rs b/editor/src/messages/portfolio/workspace/workspace_message_handler.rs index f310580dcfd..e8d053a63e0 100644 --- a/editor/src/messages/portfolio/workspace/workspace_message_handler.rs +++ b/editor/src/messages/portfolio/workspace/workspace_message_handler.rs @@ -296,6 +296,14 @@ impl MessageHandler for WorkspaceMess let panel_type = PanelType::Session; self.toggle_dockable_panel(panel_type, has_active_document, has_no_documents, responses); } + WorkspaceMessage::ToggleHistoryPanelOpen => { + if self.panel_layout.focus_document { + return; + } + + let panel_type = PanelType::History; + self.toggle_dockable_panel(panel_type, has_active_document, has_no_documents, responses); + } WorkspaceMessage::UpdatePanelsLayout => { let panel_layout = match self.panel_layout.focus_document { true => self.panel_layout.document_only_layout(), @@ -412,7 +420,7 @@ impl WorkspaceMessageHandler { PanelType::Layers => &[LayoutTarget::LayersPanelControlLeftBar, LayoutTarget::LayersPanelBottomLeftBar, LayoutTarget::LayersPanelBottomRightBar], PanelType::Data => &[LayoutTarget::DataPanel, LayoutTarget::DataPanelContext, LayoutTarget::DataPanelPath], PanelType::Session => &[LayoutTarget::SessionPanel], - PanelType::Document | PanelType::Welcome => return, + PanelType::Document | PanelType::Welcome | PanelType::History => return, }; for &layout_target in targets { @@ -441,6 +449,9 @@ impl WorkspaceMessageHandler { PanelType::Session => { responses.add(SyncMessage::RefreshPanel); } + PanelType::History => { + responses.add(HistoryMessage::Refresh); + } PanelType::Document | PanelType::Welcome => { // Re-send the welcome screen buttons layout to repopulate after a remount if has_no_documents { diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 251514b0b40..9af94c9078c 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -33,6 +33,7 @@ pub use crate::messages::portfolio::document::resource::{ResourceMessage, Resour pub use crate::messages::portfolio::document::{DocumentMessage, DocumentMessageContext, DocumentMessageDiscriminant, DocumentMessageHandler}; pub use crate::messages::portfolio::failed_documents::{FailedDocumentsMessage, FailedDocumentsMessageContext, FailedDocumentsMessageDiscriminant, FailedDocumentsMessageHandler}; pub use crate::messages::portfolio::fonts::{FontsMessage, FontsMessageContext, FontsMessageDiscriminant, FontsMessageHandler}; +pub use crate::messages::portfolio::history::{HistoryMessage, HistoryMessageContext, HistoryMessageDiscriminant, HistoryMessageHandler}; pub use crate::messages::portfolio::ingest::{IngestMessage, IngestMessageContext, IngestMessageDiscriminant, IngestMessageHandler}; pub use crate::messages::portfolio::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageDiscriminant, PersistentStateMessageHandler}; pub use crate::messages::portfolio::sync::{SyncMessage, SyncMessageContext, SyncMessageDiscriminant, SyncMessageHandler}; diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index d9d43c8260b..695626c13e2 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -12,6 +12,7 @@ import { createDialogStore, destroyDialogStore } from "/src/stores/dialog"; import { createDocumentStore, destroyDocumentStore } from "/src/stores/document"; import { createFullscreenStore, destroyFullscreenStore } from "/src/stores/fullscreen"; + import { createHistoryStore, destroyHistoryStore } from "/src/stores/history"; import { createMathExpressionStore, destroyMathExpressionStore } from "/src/stores/math-expression"; import { createNodeGraphStore, destroyNodeGraphStore } from "/src/stores/node-graph"; import { createPortfolioStore, destroyPortfolioStore } from "/src/stores/portfolio"; @@ -31,6 +32,7 @@ document: createDocumentStore(subscriptions), fullscreen: createFullscreenStore(subscriptions), nodeGraph: createNodeGraphStore(subscriptions), + history: createHistoryStore(subscriptions), portfolio: createPortfolioStore(subscriptions, editor), appWindow: createAppWindowStore(subscriptions), colorPicker: createColorPickerStore(subscriptions), @@ -61,6 +63,7 @@ destroyDocumentStore(); destroyFullscreenStore(); destroyNodeGraphStore(); + destroyHistoryStore(); destroyPortfolioStore(); destroyAppWindowStore(); destroyColorPickerStore(); diff --git a/frontend/src/components/panels/History.svelte b/frontend/src/components/panels/History.svelte new file mode 100644 index 00000000000..d76101e72fa --- /dev/null +++ b/frontend/src/components/panels/History.svelte @@ -0,0 +1,405 @@ + + + + {#if $history.following} + + Viewing a branch + editor.followHistoryBranch(undefined)} /> + + {/if} + + {#each $history.progress as row} +
+ +
+ {row.label || (row.open ? "In progress" : "Waiting to enter history")} + + {authorLabel(row)} + · {count(row.ops, "change", "changes")} + · {row.open ? "in progress" : "not yet in history"} + +
+
+ {/each} + {#each $history.rows as row (row.id)} +
+ + +
+ {#if renamingId === row.id} + commitName(row.id, detail)} /> + {:else} + {row.label} + {/if} + + {authorLabel(row)} + {#if row.time}· {when(row.time, now)}{/if} + · {count(row.deltas, "change", "changes")} + + {#if row.tags.length > 0 || taggingId === row.id} + + {#each row.tags as tag (tag)} + {tag} + {/each} + {#if taggingId === row.id} + commitTag(row.id, detail)} /> + {/if} + + {/if} + {#if row.branches.length > 0} + + {#each row.branches as branch (branch.id)} + + {/each} + + {/if} +
+ + + + {#if row.undone || row.abandoned} + editor.bringBackHistoryInteraction(row.id)} + /> + {:else} + {#if !row.head} + editor.goBackToHistoryInteraction(row.id)} + /> + {/if} + editor.removeHistoryInteraction(row.id)} + /> + {/if} + + {#if row.head}Now{/if} + {#if row.undone}Undone{/if} + {#if row.abandoned}Branch{/if} +
+ {#if row.expanded} + {#each row.details as delta (delta.id)} +
+ +
+ {delta.label} + + {delta.author} + {#if delta.time}· {when(delta.time, now)}{/if} + +
+
+ {/each} + {/if} + {/each} + {#if $history.more} +
+ editor.loadMoreHistory()} /> +
+ {/if} + {#if $history.rows.length === 0 && $history.progress.length === 0} + Nothing in the history yet. + {/if} +
+
+ + diff --git a/frontend/src/components/window/Panel.svelte b/frontend/src/components/window/Panel.svelte index 106ae1f15f8..b89b665a95b 100644 --- a/frontend/src/components/window/Panel.svelte +++ b/frontend/src/components/window/Panel.svelte @@ -4,6 +4,7 @@ import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import Data from "/src/components/panels/Data.svelte"; import Document from "/src/components/panels/Document.svelte"; + import History from "/src/components/panels/History.svelte"; import Inspector from "/src/components/panels/Inspector.svelte"; import Layers from "/src/components/panels/Layers.svelte"; import Session from "/src/components/panels/Session.svelte"; @@ -18,6 +19,7 @@ Welcome, Document, Layers, + History, Inspector, Data, Session, diff --git a/frontend/src/stores/history.ts b/frontend/src/stores/history.ts new file mode 100644 index 00000000000..01d72afdc01 --- /dev/null +++ b/frontend/src/stores/history.ts @@ -0,0 +1,35 @@ +import { writable } from "svelte/store"; +import type { Writable } from "svelte/store"; +import type { SubscriptionsRouter } from "/src/subscriptions-router"; +import type { HistoryPanelState } from "/wrapper/pkg/graphite_wasm_wrapper"; + +export type HistoryStore = ReturnType; + +const initialState: HistoryPanelState = { rows: [], progress: [], more: false, following: undefined, session: false }; + +// Store state persisted across HMR to maintain reactive subscriptions in the component tree +const store: Writable = import.meta.hot?.data?.store || writable(initialState); +if (import.meta.hot) import.meta.hot.data.store = store; + +const { subscribe, set } = store; + +let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; + +export function createHistoryStore(subscriptions: SubscriptionsRouter) { + destroyHistoryStore(); + subscriptionsRouter = subscriptions; + + subscriptions.subscribeFrontendMessage("UpdateHistoryPanel", (data) => { + set(data.state); + }); + + return { subscribe }; +} + +export function destroyHistoryStore() { + const subscriptions = subscriptionsRouter; + if (!subscriptions) return; + + subscriptions.unsubscribeFrontendMessage("UpdateHistoryPanel"); + subscriptionsRouter = undefined; +} diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 96553094bcb..251e2693f9e 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -261,6 +261,43 @@ mod editor_commands { WorkspaceMessage::FocusPanel { panel_type }.into() } + /// Show or hide an interaction's deltas + fn expand_history_interaction(id: String, expanded: bool) -> Message { + HistoryMessage::Expand { id, expanded }.into() + } + + fn load_more_history() -> Message { + HistoryMessage::LoadMore.into() + } + + fn go_back_to_history_interaction(id: String) -> Message { + HistoryMessage::GoBack { id }.into() + } + + /// Remove one interaction and keep the later ones + fn remove_history_interaction(id: String) -> Message { + HistoryMessage::RemoveStep { id }.into() + } + + /// Redo an undone interaction, or reapply an abandoned one + fn bring_back_history_interaction(id: String) -> Message { + HistoryMessage::BringBack { id }.into() + } + + /// Show a branch's line, or the head's line when `id` is `None` + fn follow_history_branch(id: Option) -> Message { + HistoryMessage::Follow { id }.into() + } + + /// Name an interaction. An empty label removes the name + fn rename_history_interaction(id: String, label: String) -> Message { + HistoryMessage::Rename { id, label }.into() + } + + fn tag_history_interaction(id: String, tag: String, on: bool) -> Message { + HistoryMessage::Tag { id, tag, on }.into() + } + fn split_panel_group(target_group: u64, direction: DockingSplitDirection, tabs: PanelTypes, active_tab_index: usize) -> Message { WorkspaceMessage::SplitPanelGroup { target_group: PanelGroupId(target_group),