diff --git a/Cargo.lock b/Cargo.lock index 5627a842..31ea554c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1988,6 +1988,15 @@ dependencies = [ "dtor", ] +[[package]] +name = "ctor" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" +dependencies = [ + "linktime-proc-macro", +] + [[package]] name = "ctor-proc-macro" version = "0.0.6" @@ -3136,6 +3145,8 @@ dependencies = [ "memchr", "mimalloc", "notify 8.2.0", + "palette", + "rimalloc", "rustc-hash 2.1.2", "serde", "serde_json", @@ -3160,6 +3171,7 @@ dependencies = [ "dispatch2", "flowstate-document", "flowstate-fidelity", + "flowstate-flow", "futures-util", "gpui", "gpui-flowtext", @@ -3271,12 +3283,15 @@ name = "flowstate-flow" version = "0.1.0" dependencies = [ "anyhow", + "flowstate-document", + "gpui-flowtext", "hotpath", + "loro", + "postcard", "rustc-hash 2.1.2", "serde", - "serde_json", - "tracing", "uuid", + "zstd", ] [[package]] @@ -3288,9 +3303,12 @@ dependencies = [ "flowstate-collab", "flowstate-document", "flowstate-docx", + "flowstate-flow", + "gpui-flowtext", "hotpath", "loro", "postcard", + "uuid", ] [[package]] @@ -3894,7 +3912,7 @@ dependencies = [ "core-text", "core-video", "cosmic-text", - "ctor", + "ctor 0.4.3", "derive_more 0.99.20", "embed-resource", "etagere", @@ -4136,7 +4154,7 @@ dependencies = [ "bindgen", "core-foundation 0.10.0", "core-video", - "ctor", + "ctor 0.4.3", "foreign-types", "metal", "objc", @@ -6047,6 +6065,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -7744,6 +7768,7 @@ dependencies = [ "fast-srgb8", "libm", "palette_derive", + "phf 0.11.3", ] [[package]] @@ -9149,6 +9174,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rimalloc" +version = "0.1.0" +source = "git+https://github.com/moggedhedien/rimalloc.git#6a5ba948803091b1a74c49c5872e86aa47c346fb" +dependencies = [ + "ctor 1.0.8", + "libc", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/crates/flowstate-collab/Cargo.toml b/crates/flowstate-collab/Cargo.toml index 39a397fc..6f24127a 100644 --- a/crates/flowstate-collab/Cargo.toml +++ b/crates/flowstate-collab/Cargo.toml @@ -11,6 +11,7 @@ base64 = { workspace = true } blake3 = "1.8.5" flowstate-document.workspace = true flowstate-fidelity.workspace = true +flowstate-flow.workspace = true gpui-flowtext = { workspace = true } hotpath.workspace = true iroh = { workspace = true } @@ -33,9 +34,9 @@ uuid = { workspace = true } zstd.workspace = true [features] -hotpath = ["hotpath/hotpath", "flowstate-document/hotpath", "gpui-flowtext/hotpath"] -hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "gpui-flowtext/hotpath-cpu"] -hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "gpui-flowtext/hotpath-alloc"] +hotpath = ["hotpath/hotpath", "flowstate-document/hotpath", "flowstate-flow/hotpath", "gpui-flowtext/hotpath"] +hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "flowstate-flow/hotpath-cpu", "gpui-flowtext/hotpath-cpu"] +hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "flowstate-flow/hotpath-alloc", "gpui-flowtext/hotpath-alloc"] [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/flowstate-collab/src/flow/cell_authority.rs b/crates/flowstate-collab/src/flow/cell_authority.rs new file mode 100644 index 00000000..0d716fba --- /dev/null +++ b/crates/flowstate-collab/src/flow/cell_authority.rs @@ -0,0 +1,217 @@ +//! `FlowCellAuthority` — a per-cell [`LocalWriteAuthority`] (flow spec Part B). +//! +//! Each open cell's `RichTextEditor` is driven by one of these through the +//! existing `RichTextEditor::set_write_authority` injection point. It +//! translates the editor's `LocalIntent`s into `FlowIntent::CellText` on the +//! shared flow handle, drains THAT cell's ordered stream, and provides exact +//! caret survival across concurrent same-cell edits via encoded Loro cursors +//! (`encode/resolve_selection_anchor`). Object/table/image intents are +//! rejected structurally — `flow_cell_surface` editors never emit them. + +use std::sync::Arc; + +use flowstate_flow::format::CellId; +use flowstate_flow::intents::FlowIntent; +use gpui_flowtext::local_intents::{ + IntentCounters, LocalCommit, LocalIntent, LocalWriteAuthority, LocalWriteOutcome, UndoOutcome, WriteRejected, +}; +use gpui_flowtext::{DocumentProjection, EditorSelection, ProjectionPatchBatch, ProjectionStreamItem}; +use loro::{ContainerTrait as _, cursor::Cursor}; +use uuid::Uuid; + +use super::cell_text::CellTextContext; +use super::handle::FlowDocHandle; +use super::runtime::{FlowUndoMeta, FlowWriteRejected}; +use crate::crdt_runtime::cursor_for_boundary; +use crate::local_write::GateHolder; + +pub struct FlowCellAuthority { + handle: Arc, + cell: CellId, +} + +impl FlowCellAuthority { + #[must_use] + pub fn new(handle: Arc, cell: CellId) -> Self { + Self { handle, cell } + } + + #[must_use] + pub fn cell_id(&self) -> CellId { + self.cell + } + + #[must_use] + pub fn handle(&self) -> &Arc { + &self.handle + } + + fn map_rejection(rejected: FlowWriteRejected) -> WriteRejected { + match rejected { + FlowWriteRejected::UnresolvedParagraph(id) => WriteRejected::UnresolvedParagraph(id), + FlowWriteRejected::UnresolvedCursor => WriteRejected::UnresolvedCursor, + FlowWriteRejected::EmptyIntent => WriteRejected::EmptyIntent, + FlowWriteRejected::GatePoisoned => WriteRejected::GatePoisoned, + FlowWriteRejected::CompensatedFailure { class, diagnostic } => WriteRejected::CompensatedFailure { class, diagnostic }, + FlowWriteRejected::CompensationFailed { class, diagnostic } => WriteRejected::CompensationFailed { class, diagnostic }, + FlowWriteRejected::UnknownSheet(_) | FlowWriteRejected::UnknownCell(_) => { + WriteRejected::StructureViolation("cell no longer exists in canonical flow state") + }, + FlowWriteRejected::StructureViolation(reason) => { + tracing::debug!(%reason, "cell write rejected as a structure violation"); + WriteRejected::StructureViolation("intent violates flow cell structure") + }, + } + } + + /// Resolve a restored undo/redo board context into THIS cell's selection + /// (only when the restored focus IS this cell). + fn selection_from_meta(&self, meta: &FlowUndoMeta) -> Option { + if meta.focused_cell != Some(self.cell) { + return None; + } + let (head_cursor, anchor_cursor) = (meta.head_cursor.as_deref()?, meta.anchor_cursor.as_deref()?); + let (head, anchor) = self.resolve_selection_anchor(head_cursor, anchor_cursor)?; + Some(EditorSelection { + anchor, + head, + anchor_affinity: gpui_flowtext::SelectionAffinity::Neutral, + head_affinity: gpui_flowtext::SelectionAffinity::Neutral, + anchor_gravity: gpui_flowtext::VisualGravity::Neutral, + head_gravity: gpui_flowtext::VisualGravity::Neutral, + }) + } +} + +impl LocalWriteAuthority for FlowCellAuthority { + fn apply(&self, intent: LocalIntent) -> Result { + let outcome = self + .handle + .apply(FlowIntent::CellText { cell_id: self.cell, intent }) + .map_err(Self::map_rejection)?; + if !outcome.changed { + return Err(WriteRejected::EmptyIntent); + } + // The projection change rides the cell's ordered stream (whole-cell + // Replace — cells are tiny); the returned batch carries only the frontier + // pair + the caret. The editor never applies the batch directly + // (`integrate_outcome` drains the stream), so an empty patch list is the + // honest representation. + Ok(LocalWriteOutcome::Committed(LocalCommit { + patches: ProjectionPatchBatch { + transaction_id: Uuid::new_v4().as_u128(), + base_frontier: Vec::new(), + new_frontier: outcome.frontier.clone(), + patches: Arc::from(Vec::new()), + }, + frontier: outcome.frontier, + version_vector: outcome.version_vector, + selection_after: outcome.selection_after, + counters: IntentCounters::default(), + })) + } + + fn undo(&self) -> Result { + let outcome = self.handle.undo().map_err(Self::map_rejection)?; + let selection = outcome + .meta + .as_ref() + .and_then(|meta| self.selection_from_meta(meta)); + Ok(UndoOutcome { + applied: outcome.applied, + selection, + }) + } + + fn redo(&self) -> Result { + let outcome = self.handle.redo().map_err(Self::map_rejection)?; + let selection = outcome + .meta + .as_ref() + .and_then(|meta| self.selection_from_meta(meta)); + Ok(UndoOutcome { + applied: outcome.applied, + selection, + }) + } + + fn undo_group_start(&self) -> Result { + self.handle.undo_group_start().map_err(Self::map_rejection) + } + + fn undo_group_end(&self) -> Result<(), WriteRejected> { + self.handle.undo_group_end().map_err(Self::map_rejection) + } + + fn drain_projection_stream(&self) -> Result, WriteRejected> { + self + .handle + .with_runtime(GateHolder::LocalIntent, |runtime| runtime.take_cell_stream(self.cell)) + .map_err(Self::map_rejection) + } + + fn canonical_projection(&self) -> Result { + self + .handle + .open_cell(self.cell) + .map_err(Self::map_rejection) + } + + fn encode_selection_anchor(&self, selection: &EditorSelection, editor_frontier: &[u8]) -> Option<(Vec, Vec)> { + let cell = self.cell; + let selection = selection.clone(); + let editor_frontier = editor_frontier.to_vec(); + let handle = Arc::clone(&self.handle); + self + .handle + .with_runtime(GateHolder::Presence, move |runtime| { + // Only encode while the editor is in sync with the core (an undrained + // import would anchor the caret against a stale projection). + if runtime.frontier() != editor_frontier { + return None; + } + let ctx = CellTextContext::resolve(runtime.doc(), cell).ok()?; + let head_unicode = ctx.unicode_for_offset(selection.head)?; + let anchor_unicode = ctx.unicode_for_offset(selection.anchor)?; + let head = cursor_for_boundary(&ctx.text, head_unicode, crate::presence::SelectionAffinity::Neutral)?; + let anchor = cursor_for_boundary(&ctx.text, anchor_unicode, crate::presence::SelectionAffinity::Neutral)?; + // Keep the flow undo context current: this is called at synced + // moments, exactly when the selection is a trustworthy capture. + let active_sheet = runtime.board_ref().cell(cell).map(|(sheet, _)| sheet.id); + handle.set_undo_context(FlowUndoMeta { + active_sheet, + focused_cell: Some(cell), + head_cursor: Some(head.encode()), + anchor_cursor: Some(anchor.encode()), + }); + Some((head.encode(), anchor.encode())) + }) + .ok()? + } + + fn resolve_selection_anchor( + &self, + head_cursor: &[u8], + anchor_cursor: &[u8], + ) -> Option<(gpui_flowtext::DocumentOffset, gpui_flowtext::DocumentOffset)> { + let cell = self.cell; + let head_cursor = head_cursor.to_vec(); + let anchor_cursor = anchor_cursor.to_vec(); + self + .handle + .with_runtime(GateHolder::Presence, move |runtime| { + let ctx = CellTextContext::resolve(runtime.doc(), cell).ok()?; + let resolve = |encoded: &[u8]| { + let cursor = Cursor::decode(encoded).ok()?; + if cursor.container != ctx.text.id() { + return None; + } + let position = runtime.doc().get_cursor_pos(&cursor).ok()?; + ctx.offset_for_unicode(position.current.pos) + }; + Some((resolve(&head_cursor)?, resolve(&anchor_cursor)?)) + }) + .ok() + .flatten() + } +} diff --git a/crates/flowstate-collab/src/flow/cell_text.rs b/crates/flowstate-collab/src/flow/cell_text.rs new file mode 100644 index 00000000..f0284a3f --- /dev/null +++ b/crates/flowstate-collab/src/flow/cell_text.rs @@ -0,0 +1,526 @@ +//! Cell-scoped rich-text resolution + executors (flow spec Part B). +//! +//! The .db8 resolution law, applied to ONE cell's flow: identity + cursor over +//! hint, reject before mutation. Cells are paragraph-only and tiny, so the +//! context is a full (O(cell)) snapshot of the live text — boundaries are the +//! `\n` positions, paragraph `i` spans `(boundary[i], boundary[i+1])`. +//! Executors mutate the live containers WITHOUT committing; the flow commit +//! path owns the single commit and the compensation law. + +use anyhow::{Context as _, Result}; +use flowstate_document::loro_schema::{ + MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_PARAGRAPH_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, +}; +use flowstate_document::paragraph_ids_by_boundary_in; +use flowstate_flow::format::CellId; +use flowstate_flow::loro_schema::{cell_paragraph_registry, cell_text, write_paragraph_record}; +use gpui_flowtext::local_intents::{FragmentBlock, LocalIntent, TextAnchor}; +use gpui_flowtext::{InputParagraph, ParagraphId, ParagraphStyle, RunStyles}; +use loro::{ContainerTrait as _, LoroDoc, LoroMap, LoroText, cursor::Cursor}; +use rustc_hash::FxHashMap; +use uuid::Uuid; + +use super::runtime::FlowWriteRejected; +use crate::crdt_runtime::sentinel_protected_delete_range; + +/// The live-text context one cell-text intent resolves against. +pub(crate) struct CellTextContext { + pub(crate) text: LoroText, + pub(crate) registry: LoroMap, + /// The cell's full text, char-indexed (cells are tiny by design). + chars: Vec, + /// `\n` boundary positions, ascending — one per paragraph row. + boundaries: Vec, + /// boundary unicode position → durable registry record key. + record_keys: FxHashMap, +} + +impl CellTextContext { + pub(crate) fn resolve(doc: &LoroDoc, cell: CellId) -> Result { + let text = cell_text(doc, cell).ok_or(FlowWriteRejected::UnknownCell(cell))?; + let registry = cell_paragraph_registry(doc, cell).ok_or(FlowWriteRejected::UnknownCell(cell))?; + let chars: Vec = text.to_string().chars().collect(); + let boundaries: Vec = chars + .iter() + .enumerate() + .filter_map(|(ix, ch)| (*ch == '\n').then_some(ix)) + .collect(); + let record_keys = paragraph_ids_by_boundary_in(doc, ®istry, &text); + Ok(Self { + text, + registry, + chars, + boundaries, + record_keys, + }) + } + + fn len_unicode(&self) -> usize { + self.chars.len() + } + + fn paragraph_count(&self) -> usize { + self.boundaries.len() + } + + /// Paragraph text span `(start, end)` in unicode positions (exclusive end). + fn paragraph_span(&self, ix: usize) -> Option<(usize, usize)> { + let start = self.boundaries.get(ix)? + 1; + let end = self + .boundaries + .get(ix + 1) + .copied() + .unwrap_or(self.len_unicode()); + Some((start, end)) + } + + fn paragraph_text(&self, ix: usize) -> Option { + let (start, end) = self.paragraph_span(ix)?; + Some(self.chars[start..end].iter().collect()) + } + + /// Map a `(paragraph_ix, byte)` render offset to a live unicode position. + pub(crate) fn unicode_for_offset(&self, offset: gpui_flowtext::DocumentOffset) -> Option { + let (start, end) = self.paragraph_span(offset.paragraph)?; + let mut unicode = start; + let mut bytes = 0usize; + for ch in &self.chars[start..end] { + if bytes >= offset.byte { + break; + } + bytes += ch.len_utf8(); + unicode += 1; + } + Some(unicode) + } + + /// Map a live unicode position back to `(paragraph_ix, byte)` render space. + pub(crate) fn offset_for_unicode(&self, unicode: usize) -> Option { + let ix = self + .boundaries + .partition_point(|boundary| *boundary < unicode) + .checked_sub(1)?; + let (start, end) = self.paragraph_span(ix)?; + let clamped = unicode.clamp(start, end); + let byte = self.chars[start..clamped] + .iter() + .map(|ch| ch.len_utf8()) + .sum(); + Some(gpui_flowtext::DocumentOffset { paragraph: ix, byte }) + } + + /// The resolution law: cursor basis first (must address THIS cell's text + /// container — foreign cursors fall back), then paragraph identity + clamped + /// byte hint. + fn resolve_anchor(&self, doc: &LoroDoc, paragraph_ids: &[ParagraphId], anchor: &TextAnchor) -> Result { + if let Some(encoded) = &anchor.cursor + && let Ok(cursor) = Cursor::decode(encoded) + && cursor.container == self.text.id() + && let Ok(position) = doc.get_cursor_pos(&cursor) + { + let pos = if cursor.id.is_some() { + position.current.pos + } else { + // Degraded id-less cursor: resolves to 0 or the whole-text end. + position.current.pos.min(self.len_unicode()) + }; + // Clamp out of the sentinel: position 0 is never a valid caret. + return Ok(pos.max(1).min(self.len_unicode())); + } + let ix = paragraph_ids + .iter() + .position(|id| *id == anchor.paragraph) + .filter(|ix| *ix < self.paragraph_count()) + .ok_or(FlowWriteRejected::UnresolvedParagraph(anchor.paragraph))?; + let text = self + .paragraph_text(ix) + .ok_or(FlowWriteRejected::UnresolvedParagraph(anchor.paragraph))?; + let mut byte = anchor.byte_hint.min(text.len()); + while byte > 0 && !text.is_char_boundary(byte) { + byte -= 1; + } + let (start, _) = self + .paragraph_span(ix) + .ok_or(FlowWriteRejected::UnresolvedParagraph(anchor.paragraph))?; + Ok(start + text[..byte].chars().count()) + } + + fn resolve_range( + &self, + doc: &LoroDoc, + paragraph_ids: &[ParagraphId], + start: &TextAnchor, + end: &TextAnchor, + ) -> Result<(usize, usize), FlowWriteRejected> { + let a = self.resolve_anchor(doc, paragraph_ids, start)?; + let b = self.resolve_anchor(doc, paragraph_ids, end)?; + Ok((a.min(b), a.max(b))) + } + + fn boundary_of_paragraph(&self, paragraph_ids: &[ParagraphId], id: ParagraphId) -> Result<(usize, usize), FlowWriteRejected> { + let ix = paragraph_ids + .iter() + .position(|candidate| *candidate == id) + .filter(|ix| *ix < self.paragraph_count()) + .ok_or(FlowWriteRejected::UnresolvedParagraph(id))?; + Ok((ix, self.boundaries[ix])) + } +} + +/// The fully-resolved cell-text execution plan (mutation-free until execute). +pub(crate) enum CellPlan { + Insert { + at: usize, + text: String, + style_override: Option, + }, + Delete { + start: usize, + len: usize, + retire: Vec, + }, + Split { + at: usize, + style: ParagraphStyle, + }, + Join { + boundary: usize, + retire: Option, + }, + Marks { + start: usize, + end: usize, + styles: RunStyles, + }, + ParagraphStyles { + boundaries: Vec, + style: ParagraphStyle, + }, + Replaces { + /// `(start, end, styles)`, sorted DESCENDING by start; same-paragraph, + /// non-overlapping. + matches: Vec<(usize, usize, Option)>, + replacement: String, + }, + Fragment { + at: usize, + paragraphs: Vec, + }, +} + +/// Resolve one editor intent against the cell context. Rejection = zero +/// mutation (I-15). +pub(crate) fn resolve_cell_plan( + doc: &LoroDoc, + ctx: &CellTextContext, + paragraph_ids: &[ParagraphId], + intent: &LocalIntent, +) -> Result { + match intent { + LocalIntent::InsertText(intent) => { + if intent.text.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + if intent.text.contains('\n') { + return Err(FlowWriteRejected::StructureViolation( + "cell text inserts are single-paragraph; splits are explicit intents".into(), + )); + } + Ok(CellPlan::Insert { + at: ctx.resolve_anchor(doc, paragraph_ids, &intent.at)?, + text: intent.text.clone(), + style_override: intent.style_override, + }) + }, + LocalIntent::DeleteRange(intent) => { + let (start, end) = ctx.resolve_range(doc, paragraph_ids, &intent.start, &intent.end)?; + let (start, len) = sentinel_protected_delete_range(start, end.saturating_sub(start)).ok_or(FlowWriteRejected::EmptyIntent)?; + let retire = ctx + .boundaries + .iter() + .filter(|boundary| (start..start + len).contains(boundary)) + .filter_map(|boundary| ctx.record_keys.get(boundary).cloned()) + .collect(); + Ok(CellPlan::Delete { start, len, retire }) + }, + LocalIntent::SplitParagraph(intent) => Ok(CellPlan::Split { + at: ctx.resolve_anchor(doc, paragraph_ids, &intent.at)?, + style: intent.inherited_style, + }), + LocalIntent::JoinParagraphs(intent) => { + let (first_ix, _) = ctx.boundary_of_paragraph(paragraph_ids, intent.first)?; + let (second_ix, second_boundary) = ctx.boundary_of_paragraph(paragraph_ids, intent.second)?; + if second_ix != first_ix + 1 { + return Err(FlowWriteRejected::StructureViolation( + "join targets are no longer adjacent paragraphs".into(), + )); + } + Ok(CellPlan::Join { + boundary: second_boundary, + retire: ctx.record_keys.get(&second_boundary).cloned(), + }) + }, + LocalIntent::SetMarks(intent) => { + let (start, end) = ctx.resolve_range(doc, paragraph_ids, &intent.start, &intent.end)?; + if start == end { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::Marks { + start, + end, + styles: intent.styles, + }) + }, + LocalIntent::SetParagraphStyle(intent) => { + let (_, boundary) = ctx.boundary_of_paragraph(paragraph_ids, intent.paragraph)?; + Ok(CellPlan::ParagraphStyles { + boundaries: vec![boundary], + style: intent.style, + }) + }, + LocalIntent::SetParagraphStyles(intent) => { + // Stale targets are skipped, not rejected (the .db8 batch law). + let boundaries: Vec = intent + .paragraphs + .iter() + .filter_map(|paragraph| ctx.boundary_of_paragraph(paragraph_ids, *paragraph).ok()) + .map(|(_, boundary)| boundary) + .collect(); + if boundaries.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::ParagraphStyles { + boundaries, + style: intent.style, + }) + }, + LocalIntent::ReplaceMatches(intent) => { + if intent.replacement.contains('\n') { + return Err(FlowWriteRejected::StructureViolation( + "replacement must not contain structural characters".into(), + )); + } + let mut matches: Vec<(usize, usize, Option)> = Vec::new(); + for candidate in &intent.matches { + let Ok((start, end)) = ctx.resolve_range(doc, paragraph_ids, &candidate.start, &candidate.end) else { + continue; + }; + // Same-paragraph, non-collapsed: a range crossing a boundary was moved + // by concurrent edits — skip, don't reject. + if start == end + || ctx + .boundaries + .iter() + .any(|boundary| (start..end).contains(boundary)) + { + continue; + } + matches.push((start, end, candidate.styles)); + } + matches.sort_by_key(|entry| std::cmp::Reverse(entry.0)); + // Descending by start: drop a match that overlaps the previously kept + // (higher-positioned) one — its end reaches past that match's start. + matches.dedup_by(|current, previous| current.1 > previous.0); + if matches.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::Replaces { + matches, + replacement: intent.replacement.clone(), + }) + }, + LocalIntent::InsertRichFragment(intent) => { + let mut paragraphs = Vec::with_capacity(intent.blocks.len()); + for block in &intent.blocks { + match block { + FragmentBlock::Paragraph(paragraph) => paragraphs.push(paragraph.clone()), + FragmentBlock::Object(_) => { + return Err(FlowWriteRejected::StructureViolation("cell flows are paragraph-only".into())); + }, + } + } + if paragraphs.is_empty() { + return Err(FlowWriteRejected::EmptyIntent); + } + Ok(CellPlan::Fragment { + at: ctx.resolve_anchor(doc, paragraph_ids, &intent.at)?, + paragraphs, + }) + }, + LocalIntent::InsertObject(_) + | LocalIntent::ReplaceObject(_) + | LocalIntent::DeleteBlocks(_) + | LocalIntent::MoveBlock(_) + | LocalIntent::ReplaceEquationSourceRange(_) + | LocalIntent::ReplaceImageAltText(_) + | LocalIntent::ReplaceImageCaption(_) + | LocalIntent::SetImageLayout(_) + | LocalIntent::Table(_) => Err(FlowWriteRejected::StructureViolation("cell flows are paragraph-only".into())), + } +} + +/// Execute a resolved plan against the live containers. Errors here trigger +/// the commit path's compensation (`revert_to`, origin `"repair"`). +pub(crate) fn execute_cell_plan(ctx: &CellTextContext, plan: &CellPlan) -> Result> { + match plan { + CellPlan::Insert { at, text, style_override } => { + ctx.text.insert(*at, text).context("inserting cell text")?; + let inserted = text.chars().count(); + if let Some(styles) = style_override + && inserted > 0 + { + replace_run_styles(&ctx.text, *at..*at + inserted, *styles)?; + } + Ok(Some(at + inserted)) + }, + CellPlan::Delete { start, len, retire } => { + ctx + .text + .delete(*start, *len) + .context("deleting cell text range")?; + for key in retire { + if ctx.registry.get(key).is_some() { + ctx + .registry + .delete(key) + .context("retiring merged-away cell paragraph record")?; + } + } + Ok(Some(*start)) + }, + CellPlan::Split { at, style } => { + ctx + .text + .insert(*at, "\n") + .context("inserting cell split boundary")?; + ctx + .text + .mark(*at..*at + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(*style)) + .context("marking cell split style")?; + // Sentinel hygiene: strip expand-`After` run keys off the boundary. + for key in [MARK_RUN_SEMANTIC_STYLE, MARK_HIGHLIGHT_STYLE, MARK_DIRECT_UNDERLINE, MARK_STRIKETHROUGH] { + ctx + .text + .unmark(*at..*at + 1, key) + .context("unmarking split boundary run keys")?; + } + let key = format!("paragraph.{}", Uuid::new_v4().as_u128()); + write_paragraph_record(&ctx.registry, &ctx.text, &key, *at).context("writing split paragraph record")?; + Ok(Some(at + 1)) + }, + CellPlan::Join { boundary, retire } => { + ctx + .text + .delete(*boundary, 1) + .context("deleting join boundary")?; + if let Some(key) = retire + && ctx.registry.get(key).is_some() + { + ctx + .registry + .delete(key) + .context("retiring joined-away paragraph record")?; + } + Ok(Some(*boundary)) + }, + CellPlan::Marks { start, end, styles } => { + replace_run_styles(&ctx.text, *start..*end, *styles)?; + Ok(None) + }, + CellPlan::ParagraphStyles { boundaries, style } => { + for boundary in boundaries { + ctx + .text + .mark(*boundary..*boundary + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(*style)) + .context("marking cell paragraph style")?; + } + Ok(None) + }, + CellPlan::Replaces { matches, replacement } => { + let mut caret = None; + for (start, end, styles) in matches { + ctx + .text + .delete(*start, end - start) + .context("deleting replaced match")?; + if !replacement.is_empty() { + ctx + .text + .insert(*start, replacement) + .context("inserting match replacement")?; + let len = replacement.chars().count(); + if let Some(styles) = styles { + replace_run_styles(&ctx.text, *start..*start + len, *styles)?; + } + caret = Some(start + len); + } else { + caret = Some(*start); + } + } + Ok(caret) + }, + CellPlan::Fragment { at, paragraphs } => { + let mut pos = *at; + for (ix, paragraph) in paragraphs.iter().enumerate() { + if ix > 0 { + ctx + .text + .insert(pos, "\n") + .context("inserting fragment boundary")?; + ctx + .text + .mark(pos..pos + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(paragraph.style)) + .context("marking fragment paragraph style")?; + let key = format!("paragraph.{}", Uuid::new_v4().as_u128()); + write_paragraph_record(&ctx.registry, &ctx.text, &key, pos).context("writing fragment paragraph record")?; + pos += 1; + } + for run in ¶graph.runs { + let len = run.text.chars().count(); + if len == 0 { + continue; + } + ctx + .text + .insert(pos, &run.text) + .context("inserting fragment run")?; + if run.styles != RunStyles::default() { + replace_run_styles(&ctx.text, pos..pos + len, run.styles)?; + } + pos += len; + } + } + Ok(Some(pos)) + }, + } +} + +/// Unmark-then-set over a range (the .db8 `mark_run_styles` law: `SetMarks` is a +/// full restatement of the range's run styles). +fn replace_run_styles(text: &LoroText, range: std::ops::Range, styles: RunStyles) -> Result<()> { + for key in [MARK_RUN_SEMANTIC_STYLE, MARK_HIGHLIGHT_STYLE, MARK_DIRECT_UNDERLINE, MARK_STRIKETHROUGH] { + text + .unmark(range.clone(), key) + .context("unmarking run style key")?; + } + if let gpui_flowtext::RunSemanticStyle::Custom(slot) = styles.semantic { + text.mark(range.clone(), MARK_RUN_SEMANTIC_STYLE, i64::from(slot))?; + } + if let Some(gpui_flowtext::HighlightStyle::Custom(slot)) = styles.highlight { + text.mark(range.clone(), MARK_HIGHLIGHT_STYLE, i64::from(slot))?; + } + if styles.direct_underline { + text.mark(range.clone(), MARK_DIRECT_UNDERLINE, true)?; + } + if styles.strikethrough { + text.mark(range, MARK_STRIKETHROUGH, true)?; + } + Ok(()) +} + +pub(crate) fn paragraph_style_value(style: ParagraphStyle) -> i64 { + match style { + ParagraphStyle::Normal => 0, + ParagraphStyle::Custom(slot) => i64::from(slot) + 1, + } +} diff --git a/crates/flowstate-collab/src/flow/commit.rs b/crates/flowstate-collab/src/flow/commit.rs new file mode 100644 index 00000000..5ff75b02 --- /dev/null +++ b/crates/flowstate-collab/src/flow/commit.rs @@ -0,0 +1,720 @@ +//! Flow intent execution: resolve → mutate → commit → derive → publish (the +//! .db8 commit law at board scale). ALL frontier logic for the flow write path +//! lives in this file. +//! +//! Phases, verbatim from `local_write/commit.rs`: +//! 1. **Resolve** against the maintained board + live containers; any failure +//! rejects the intent before the doc is touched (I-15). +//! 2. **Mutate** through the `flowstate_flow::loro_schema` writers / the +//! cell-text executors. A mid-apply error triggers the I-10 compensation +//! (`revert_to` + `"repair"`-origin commits), never a partial escape. +//! 3. **Commit** exactly once — origin `"local"`, message = intent class. +//! 4. **Derive** the projection change in place (structural intents mutate the +//! maintained board directly; content intents rematerialize ONE cell) and +//! push the ordered streams; keystrokes touch the board stream only when +//! the cell's summary actually changed. +//! 5. **Publish**: export `updates(vv_before)` onto the publish queue. +//! +//! One intent = one Loro commit = one undo-group member. + +use anyhow::{Context as _, Result}; +use flowstate_flow::format::{CellId, SheetId}; +use flowstate_flow::intents::{CellPlacement, CellSeed, FlowIntent, RelativePosition}; +use flowstate_flow::projection::{Cell, Sheet}; +use flowstate_flow::{board_ops, loro_schema}; +use gpui_flowtext::{CursorEndpoint, SelectionAffinity, SelectionSnapshot, VisualGravity}; +use loro::LoroDoc; +use uuid::Uuid; + +use super::cell_text::{self, CellTextContext}; +use super::runtime::{FlowRuntime, FlowWriteOutcome, FlowWriteRejected}; +use crate::crdt_runtime::cursor_for_boundary; + +/// Execute one flow intent against the gate-held runtime. +#[hotpath::measure] +pub(crate) fn apply_flow_intent(core: &mut FlowRuntime, intent: &FlowIntent) -> Result { + let class = intent.class(); + let doc = core.doc().clone(); + let frontier_before = doc.state_frontiers(); + let vv_before = doc.oplog_vv(); + + // ---- Phases 1+2: resolve (reject before mutation), then mutate ------------ + let derived = match execute_intent(core, &doc, intent) { + Ok(Some(derived)) => derived, + // Resolved to a genuine no-op: nothing was mutated, nothing to commit. + Ok(None) => { + return Ok(FlowWriteOutcome { + changed: false, + frontier: core.frontier(), + version_vector: doc.state_vv().encode(), + selection_after: None, + }); + }, + Err(ExecuteError::Rejected(rejected)) => return Err(rejected), + Err(ExecuteError::MidApply(error)) => return Err(compensate_failed_intent(core, &doc, &frontier_before, &vv_before, class, &error)), + }; + + // ---- Phase 3: one commit, origin local, class message --------------------- + doc.set_next_commit_origin("local"); + doc.set_next_commit_message(class); + doc.commit(); + + // ---- Phase 4: derive + streams -------------------------------------------- + let mut selection_after = None; + match derived { + Derived::Board => core.push_board_stream(), + Derived::Content { cell, caret } => { + let summary_changed = core + .refresh_cell(cell) + .map_err(|error| compensate_failed_intent(core, &doc, &frontier_before, &vv_before, class, &error))?; + if summary_changed { + core.push_board_stream(); + } + selection_after = caret.and_then(|caret| cell_selection_snapshot(&doc, cell, caret)); + }, + } + + // ---- Phase 5: publish ------------------------------------------------------ + core.queue_local_update_publish(&vv_before); + + #[cfg(debug_assertions)] + core.audit_board_against_rebuild(class); + + Ok(FlowWriteOutcome { + changed: true, + frontier: core.frontier(), + version_vector: doc.state_vv().encode(), + selection_after, + }) +} + +/// What the committed intent changed, for stream derivation. +enum Derived { + /// Structural board change (already applied to the maintained board). + Board, + /// One cell's rich text changed (cell rematerialization + summary check). + Content { cell: CellId, caret: Option }, +} + +enum ExecuteError { + Rejected(FlowWriteRejected), + MidApply(anyhow::Error), +} + +impl From for ExecuteError { + fn from(rejected: FlowWriteRejected) -> Self { + Self::Rejected(rejected) + } +} + +impl From for ExecuteError { + fn from(error: anyhow::Error) -> Self { + Self::MidApply(error) + } +} + +fn sheet_of(core: &FlowRuntime, sheet_id: SheetId) -> Result<&Sheet, FlowWriteRejected> { + core + .board_ref() + .sheet(sheet_id) + .ok_or(FlowWriteRejected::UnknownSheet(sheet_id)) +} + +/// Resolve + mutate. `Ok(None)` = resolved no-op (zero mutation). The board is +/// updated in place for structural intents (the derive step's authority). +#[allow(clippy::too_many_lines, reason = "one arm per intent class; each arm is short and self-contained")] +fn execute_intent(core: &mut FlowRuntime, doc: &LoroDoc, intent: &FlowIntent) -> Result, ExecuteError> { + match intent { + FlowIntent::CreateSheet { + sheet_id, + name, + sheet_type_id, + } => { + if core.board_ref().format.sheet_type(*sheet_type_id).is_none() { + return Err(FlowWriteRejected::StructureViolation("unknown sheet type".into()).into()); + } + if core.board_ref().sheet(*sheet_id).is_some() { + return Err(FlowWriteRejected::StructureViolation("sheet id already exists".into()).into()); + } + loro_schema::write_sheet(doc, *sheet_id, name, *sheet_type_id, usize::MAX).context("writing sheet record")?; + core.board_mut().sheets.push(Sheet { + id: *sheet_id, + name: name.clone(), + sheet_type_id: *sheet_type_id, + cells: Vec::new(), + annotations: Vec::new(), + }); + Ok(Some(Derived::Board)) + }, + FlowIntent::RenameSheet { sheet_id, name } => { + sheet_of(core, *sheet_id)?; + loro_schema::rename_sheet(doc, *sheet_id, name).context("renaming sheet")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet.name = name.clone(); + } + Ok(Some(Derived::Board)) + }, + FlowIntent::DeleteSheet { sheet_id } => { + sheet_of(core, *sheet_id)?; + loro_schema::remove_sheet(doc, *sheet_id).context("removing sheet")?; + let cells: Vec = sheet_of(core, *sheet_id) + .map(|sheet| sheet.cells.iter().map(|cell| cell.id).collect()) + .unwrap_or_default(); + for cell in cells { + core.close_cell(cell); + } + core + .board_mut() + .sheets + .retain(|sheet| sheet.id != *sheet_id); + Ok(Some(Derived::Board)) + }, + FlowIntent::MoveSheet { sheet_id, target_index } => { + sheet_of(core, *sheet_id)?; + loro_schema::move_sheet(doc, *sheet_id, *target_index).context("moving sheet")?; + let board = core.board_mut(); + let from = board + .sheets + .iter() + .position(|sheet| sheet.id == *sheet_id) + .expect("sheet resolved above"); + let sheet = board.sheets.remove(from); + let to = (*target_index).min(board.sheets.len()); + board.sheets.insert(to, sheet); + Ok(Some(Derived::Board)) + }, + FlowIntent::AddCell { + sheet_id, + cell_id, + placement, + seed, + } => { + let sheet = sheet_of(core, *sheet_id)?; + if core.board_ref().cell(*cell_id).is_some() { + return Err(FlowWriteRejected::StructureViolation("cell id already exists".into()).into()); + } + let columns = board_ops::sheet_column_ids(core.board_ref(), *sheet_id) + .map_err(|_| FlowWriteRejected::StructureViolation("unknown sheet type".into()))?; + let (column_index, insertion_index, parent) = resolve_placement(core, sheet.id, &columns, placement)?; + let column_id = columns[column_index]; + let cell_map = loro_schema::write_cell(doc, *sheet_id, *cell_id, column_id, parent, insertion_index).context("writing cell record")?; + match seed { + CellSeed::Empty => { + loro_schema::seed_cell_flow(&cell_map).context("seeding cell flow")?; + }, + CellSeed::Paragraphs(paragraphs) => { + loro_schema::cell_flow_from_paragraphs(&cell_map, paragraphs).context("writing seeded cell content")?; + }, + } + let summary = flowstate_flow::loro_projection::materialize_cell_rows(doc, *cell_id) + .map(|rows| flowstate_flow::loro_projection::summary_from_rows(&rows.blocks)) + .unwrap_or_default(); + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + let index = insertion_index.min(sheet.cells.len()); + sheet.cells.insert( + index, + Cell { + id: *cell_id, + column_id, + parent_id: parent, + summary, + }, + ); + } + canonicalize_sheet_order(core, doc, *sheet_id)?; + Ok(Some(Derived::Board)) + }, + FlowIntent::DeleteCell { sheet_id, cell_id } => { + let sheet = sheet_of(core, *sheet_id)?; + if sheet.cell(*cell_id).is_none() { + return Err(FlowWriteRejected::UnknownCell(*cell_id).into()); + } + // Canonical child orphaning IN the delete commit (the old projection + // law), so the materializer never has to normalize a dangling parent. + let children: Vec = sheet + .cells + .iter() + .filter(|cell| cell.parent_id == Some(*cell_id)) + .map(|cell| cell.id) + .collect(); + for child in &children { + let map = loro_schema::cell_map(doc, *child).context("resolving child cell for orphaning")?; + loro_schema::set_cell_parent(&map, None).context("orphaning child cell")?; + } + loro_schema::remove_cell(doc, *sheet_id, *cell_id).context("removing cell record")?; + core.close_cell(*cell_id); + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet.cells.retain(|cell| cell.id != *cell_id); + for cell in &mut sheet.cells { + if cell.parent_id == Some(*cell_id) { + cell.parent_id = None; + } + } + } + // Orphaned children may now interleave another subtree's span; restore + // the canonical linearization inside this same commit. + canonicalize_sheet_order(core, doc, *sheet_id)?; + Ok(Some(Derived::Board)) + }, + FlowIntent::MoveCellSubtree { sheet_id, cell_id, drop } => { + let sheet = sheet_of(core, *sheet_id)?; + if sheet.cell(*cell_id).is_none() { + return Err(FlowWriteRejected::UnknownCell(*cell_id).into()); + } + let columns = board_ops::sheet_column_ids(core.board_ref(), *sheet_id) + .map_err(|_| FlowWriteRejected::StructureViolation("unknown sheet type".into()))?; + // ONE law for preview and commit: the same pure move the drag preview + // ran, applied to a clone, then translated into order-list moves + + // column/parent rewrites. + let mut target = sheet.clone(); + board_ops::apply_move_subtree(&mut target, &columns, *cell_id, *drop) + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}")))?; + if !board_ops::sheet_topology_ok(&target, &columns) { + return Err(FlowWriteRejected::StructureViolation("move would violate sheet topology".into()).into()); + } + // The committed order is the canonical linearization of the moved sheet + // (an arbitrary-index root drop can land inside another subtree's span); + // the drag preview applies the identical step. + board_ops::canonicalize_sheet(&mut target); + let before = sheet.clone(); + let sheet_map = loro_schema::sheet_map(doc, *sheet_id).context("resolving sheet map")?; + // Column/parent rewrites for changed subtree members (LWW fields). + for cell in &target.cells { + let old = before + .cell(cell.id) + .context("moved cell missing from pre-state")?; + if old.column_id != cell.column_id || old.parent_id != cell.parent_id { + let map = loro_schema::cell_map(doc, cell.id).context("resolving moved cell map")?; + if old.column_id != cell.column_id { + loro_schema::set_cell_column(&map, cell.column_id).context("rewriting moved cell column")?; + } + if old.parent_id != cell.parent_id { + loro_schema::set_cell_parent(&map, cell.parent_id).context("rewriting moved cell parent")?; + } + } + } + // Order-list moves ONLY (never a cell map/text write): minimal `mov` + // sequence transforming the live order into the target order. + let target_ids: Vec = target + .cells + .iter() + .map(|cell| cell.id.to_string()) + .collect(); + apply_order_diff(&sheet_map, &target_ids).context("applying order-list moves")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + *sheet = target; + } + Ok(Some(Derived::Board)) + }, + FlowIntent::SetCellStruck { sheet_id, cell_id, struck } => { + let sheet = sheet_of(core, *sheet_id)?; + let cell = sheet + .cell(*cell_id) + .ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + if cell.summary.struck == *struck { + return Ok(None); + } + let text = loro_schema::cell_text(doc, *cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + let len = text.len_unicode(); + if len <= 1 { + return Ok(None); + } + // A whole-text mark (expand-`After`), so concurrent typing merges UNDER + // the strike char-level instead of a record-blob LWW fight. + if *struck { + text + .mark(1..len, flowstate_document::MARK_STRIKETHROUGH, true) + .context("marking cell strikethrough")?; + } else { + text + .unmark(1..len, flowstate_document::MARK_STRIKETHROUGH) + .context("unmarking cell strikethrough")?; + } + Ok(Some(Derived::Content { cell: *cell_id, caret: None })) + }, + FlowIntent::EnsureCellEditable { sheet_id, cell_id } => { + let sheet = sheet_of(core, *sheet_id)?; + let cell = sheet + .cell(*cell_id) + .ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + if cell.summary.uses_summary_projection { + return Ok(None); + } + let text = loro_schema::cell_text(doc, *cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + if text.len_unicode() == 0 { + return Err(FlowWriteRejected::StructureViolation("cell flow is unseeded".into()).into()); + } + // Restyle the FIRST paragraph (boundary 0) to the editable tag style. + text + .mark( + 0..1, + flowstate_document::MARK_PARAGRAPH_STYLE, + cell_text::paragraph_style_value(loro_schema::CELL_SEED_PARAGRAPH_STYLE), + ) + .context("restyling first paragraph to the tag style")?; + Ok(Some(Derived::Content { cell: *cell_id, caret: None })) + }, + FlowIntent::ReplaceCellContent { + sheet_id, + cell_id, + paragraphs, + } => { + let sheet = sheet_of(core, *sheet_id)?; + if sheet.cell(*cell_id).is_none() { + return Err(FlowWriteRejected::UnknownCell(*cell_id).into()); + } + let cell_map = loro_schema::cell_map(doc, *cell_id).ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + loro_schema::cell_flow_from_paragraphs(&cell_map, paragraphs).context("replacing cell content")?; + Ok(Some(Derived::Content { cell: *cell_id, caret: None })) + }, + FlowIntent::AddAnnotation { sheet_id, stroke } => { + sheet_of(core, *sheet_id)?; + if stroke.sheet_id != *sheet_id { + return Err(FlowWriteRejected::StructureViolation("annotation sheet id mismatch".into()).into()); + } + loro_schema::put_annotation(doc, stroke).context("writing annotation stroke")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet + .annotations + .retain(|existing| existing.id != stroke.id); + sheet.annotations.push(stroke.clone()); + sheet.annotations.sort_by_key(|stroke| stroke.id); + } + Ok(Some(Derived::Board)) + }, + FlowIntent::DeleteAnnotation { + sheet_id, + stroke_id, + originator, + } => { + let sheet = sheet_of(core, *sheet_id)?; + let owned = sheet + .annotations + .iter() + .any(|stroke| stroke.id == *stroke_id && &stroke.originator == originator); + if !owned { + return Ok(None); + } + loro_schema::delete_annotation(doc, *stroke_id).context("deleting annotation stroke")?; + if let Some(sheet) = core.board_mut().sheet_mut(*sheet_id) { + sheet.annotations.retain(|stroke| stroke.id != *stroke_id); + } + Ok(Some(Derived::Board)) + }, + FlowIntent::ClearAnnotations { sheet_id, originator } => { + if let Some(sheet_id) = sheet_id { + sheet_of(core, *sheet_id)?; + } + let doomed: Vec = core + .board_ref() + .sheets + .iter() + .filter(|sheet| sheet_id.is_none_or(|target| sheet.id == target)) + .flat_map(|sheet| &sheet.annotations) + .filter(|stroke| &stroke.originator == originator) + .map(|stroke| stroke.id) + .collect(); + if doomed.is_empty() { + return Ok(None); + } + for stroke in &doomed { + loro_schema::delete_annotation(doc, *stroke).context("clearing annotation stroke")?; + } + for sheet in &mut core.board_mut().sheets { + if sheet_id.is_none_or(|target| sheet.id == target) { + sheet + .annotations + .retain(|stroke| &stroke.originator != originator); + } + } + Ok(Some(Derived::Board)) + }, + FlowIntent::CellText { cell_id, intent } => { + let (owning_sheet, _) = core + .board_ref() + .cell(*cell_id) + .ok_or(FlowWriteRejected::UnknownCell(*cell_id))?; + let _ = owning_sheet; + let ctx = CellTextContext::resolve(doc, *cell_id)?; + // Identity basis: the OPEN cell's cached paragraph ids (the projection + // the intent's ids came from); fall back to a fresh materialization for + // a not-yet-open cell. + let paragraph_ids: Vec = match core.cells.get(cell_id) { + Some(entry) => entry.paragraph_ids.clone(), + None => flowstate_flow::loro_projection::materialize_cell_rows(doc, *cell_id) + .map(|rows| rows.paragraph_ids) + .map_err(|_| FlowWriteRejected::UnknownCell(*cell_id))?, + }; + let plan = cell_text::resolve_cell_plan(doc, &ctx, ¶graph_ids, intent)?; + let caret = cell_text::execute_cell_plan(&ctx, &plan).context("executing cell text plan")?; + Ok(Some(Derived::Content { cell: *cell_id, caret })) + }, + } +} + +/// Resolve a new cell's placement into `(column_index, flat_insertion_index, +/// parent)` against the maintained board — the former `add_*` quintet as one +/// law. +fn resolve_placement( + core: &FlowRuntime, + sheet_id: SheetId, + columns: &[Uuid], + placement: &CellPlacement, +) -> Result<(usize, usize, Option), FlowWriteRejected> { + let board = core.board_ref(); + let sheet = board + .sheet(sheet_id) + .ok_or(FlowWriteRejected::UnknownSheet(sheet_id))?; + match placement { + CellPlacement::ColumnEnd { column_index } => { + if *column_index >= columns.len() { + return Err(FlowWriteRejected::StructureViolation("column index out of range".into())); + } + Ok((*column_index, sheet.cells.len(), None)) + }, + CellPlacement::ColumnTop { column_index } => { + if *column_index >= columns.len() { + return Err(FlowWriteRejected::StructureViolation("column index out of range".into())); + } + Ok((*column_index, 0, None)) + }, + CellPlacement::Sibling { of, position } => { + let index = sheet + .cells + .iter() + .position(|cell| cell.id == *of) + .ok_or(FlowWriteRejected::UnknownCell(*of))?; + let source = &sheet.cells[index]; + let column = columns + .iter() + .position(|column| *column == source.column_id) + .ok_or_else(|| FlowWriteRejected::StructureViolation("sibling references unknown column".into()))?; + let insertion = match position { + RelativePosition::Before => index, + // AFTER the sibling's WHOLE subtree: the canonical order keeps every + // subtree contiguous, so "after the sibling" can never mean "between + // the sibling and its responses". + RelativePosition::After => { + let subtree = board_ops::subtree_cell_ids(sheet, *of); + sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| subtree.contains(&cell.id)) + .map(|(index, _)| index) + .max() + .unwrap_or(index) + + 1 + }, + }; + Ok((column, insertion, source.parent_id)) + }, + CellPlacement::FirstResponseTo { parent } | CellPlacement::ResponseTo { parent } => { + let parent_cell = sheet + .cell(*parent) + .ok_or(FlowWriteRejected::UnknownCell(*parent))?; + let parent_column = columns + .iter() + .position(|column| *column == parent_cell.column_id) + .ok_or_else(|| FlowWriteRejected::StructureViolation("parent references unknown column".into()))?; + let child_column = parent_column + 1; + if child_column >= columns.len() { + return Err(FlowWriteRejected::StructureViolation("rightmost cells cannot receive responses".into())); + } + let insertion = match placement { + CellPlacement::FirstResponseTo { .. } => board_ops::child_prepend_index(board, sheet_id, *parent), + _ => board_ops::child_append_index(board, sheet_id, *parent), + } + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}")))?; + Ok((child_column, insertion, Some(*parent))) + }, + } +} + +/// Re-linearize a sheet canonically after a structural mutation, in BOTH +/// representations inside the same commit: the maintained board's cell vec and +/// the raw Loro order list. Keeps "raw order == canonical order" a write-path +/// invariant instead of a view-time repair, so the materializer's DFS +/// normalization is a no-op on locally-produced states. +fn canonicalize_sheet_order(core: &mut FlowRuntime, doc: &LoroDoc, sheet_id: SheetId) -> Result<(), ExecuteError> { + let Some(sheet) = core.board_mut().sheet_mut(sheet_id) else { + return Ok(()); + }; + board_ops::canonicalize_sheet(sheet); + let target: Vec = sheet.cells.iter().map(|cell| cell.id.to_string()).collect(); + let sheet_map = loro_schema::sheet_map(doc, sheet_id).context("resolving sheet map for canonicalization")?; + // ALWAYS diff against the live raw order: after a merge, raw may sit in a + // non-canonical interleaving (normalized only in the materialized view), so + // "maintained unchanged" does not imply "raw already matches". + if loro_schema::cell_order_ids(&sheet_map) != target { + apply_order_diff(&sheet_map, &target).context("re-linearizing sheet order")?; + } + Ok(()) +} + +/// Reconcile the live cell order onto `target`: drop dead/duplicate entries, +/// append missing ones, then a minimal `mov` sequence (only displaced elements +/// move; a subtree move emits one `mov` per member). Handles post-merge raw +/// orders that carry liveness drift the materialized view already normalized. +fn apply_order_diff(sheet_map: &loro::LoroMap, target: &[String]) -> Result<()> { + let order = loro_schema::cell_order_list(sheet_map).context("resolving cell order list")?; + reconcile_order_list(&order, &loro_schema::cell_order_ids(sheet_map), target) +} + +/// The generic movable-list reconcile behind [`apply_order_diff`], shared with +/// the import-side canonicalization repair (board sheet order + cell orders). +pub(crate) fn reconcile_order_list(order: &loro::LoroMovableList, current: &[String], target: &[String]) -> Result<()> { + let mut working = current.to_vec(); + let target_set: std::collections::HashSet<&String> = target.iter().collect(); + // Dead or duplicate entries, removed back-to-front so indices stay live. + let mut seen: std::collections::HashSet<&String> = std::collections::HashSet::new(); + let mut doomed: Vec = Vec::new(); + for (index, id) in working.iter().enumerate() { + if !target_set.contains(id) || !seen.insert(id) { + doomed.push(index); + } + } + for index in doomed.into_iter().rev() { + order + .delete(index, 1) + .context("dropping dead order entry")?; + working.remove(index); + } + // Missing target entries append (their canonical position lands via `mov`). + for id in target { + if !working.iter().any(|entry| entry == id) { + order + .insert(working.len(), id.as_str()) + .context("appending missing order entry")?; + working.push(id.clone()); + } + } + anyhow::ensure!( + working.len() == target.len(), + "order reconcile left mismatched sets ({} live vs {} target)", + working.len(), + target.len() + ); + for index in 0..target.len() { + if working[index] == target[index] { + continue; + } + let from = working + .iter() + .position(|id| *id == target[index]) + .context("target order entry missing from live order")?; + order.mov(from, index).context("moving order entry")?; + let id = working.remove(from); + working.insert(index, id); + } + Ok(()) +} + +/// Import-side canonicalization repair (the flow mirror of +/// `schedule_projection_repairs`): rewrite the raw sheet/cell order lists to +/// the canonical linearization the materialized view already shows, under the +/// `"repair"` origin (undo-excluded), publishing the pass like any commit. +/// Returns whether anything was rewritten. The caller caps invocations. +pub(crate) fn repair_canonical_orders(core: &mut FlowRuntime) -> Result { + let doc = core.doc().clone(); + let vv_before = doc.oplog_vv(); + let mut changed = false; + + let target_sheets: Vec = core + .board_ref() + .sheets + .iter() + .map(|sheet| sheet.id.to_string()) + .collect(); + let sheet_order = loro_schema::sheet_order(&doc); + let current_sheets = loro_schema::sheet_order_ids(&doc); + if current_sheets != target_sheets { + reconcile_order_list(&sheet_order, ¤t_sheets, &target_sheets).context("repairing sheet order")?; + changed = true; + } + + let sheets: Vec<(SheetId, Vec)> = core + .board_ref() + .sheets + .iter() + .map(|sheet| (sheet.id, sheet.cells.iter().map(|cell| cell.id.to_string()).collect())) + .collect(); + for (sheet_id, target) in sheets { + let Some(sheet_map) = loro_schema::sheet_map(&doc, sheet_id) else { + continue; + }; + if loro_schema::cell_order_ids(&sheet_map) != target { + apply_order_diff(&sheet_map, &target).context("repairing cell order")?; + changed = true; + } + } + + if changed { + doc.set_next_commit_origin("repair"); + doc.set_next_commit_message("flow-order-canonicalization"); + doc.commit(); + core.queue_local_update_publish(&vv_before); + } + Ok(changed) +} + +/// Cursor-backed caret snapshot after a cell-text intent (spec §8 shape). +fn cell_selection_snapshot(doc: &LoroDoc, cell: CellId, caret_unicode: usize) -> Option { + let text = loro_schema::cell_text(doc, cell)?; + let cursor = cursor_for_boundary(&text, caret_unicode, crate::presence::SelectionAffinity::Neutral)?; + let ctx = CellTextContext::resolve(doc, cell).ok()?; + let offset = ctx + .offset_for_unicode(caret_unicode) + .unwrap_or(gpui_flowtext::DocumentOffset { paragraph: 0, byte: 0 }); + let endpoint = CursorEndpoint { + cursor: cursor.encode(), + delta: 0, + affinity: SelectionAffinity::Neutral, + gravity: VisualGravity::Neutral, + offset, + }; + Some(SelectionSnapshot { + anchor: endpoint.clone(), + head: endpoint, + }) +} + +/// I-10 recovery, verbatim law from the .db8 side: repair origin BEFORE +/// `revert_to` (its internal diff commits the partial), explicit repair commit +/// for the inverse ops, ONE publish payload covering partial + inverse, then a +/// defensive full refresh. +fn compensate_failed_intent( + core: &mut FlowRuntime, + doc: &LoroDoc, + frontier_before: &loro::Frontiers, + vv_before: &loro::VersionVector, + class: &'static str, + error: &anyhow::Error, +) -> FlowWriteRejected { + tracing::error!(class, %error, "flow intent failed mid-apply; compensating via revert_to (I-10)"); + doc.set_next_commit_origin("repair"); + doc.set_next_commit_message("flow-intent-compensation"); + if let Err(revert_error) = doc.revert_to(frontier_before) { + tracing::error!(class, %revert_error, "revert_to during flow intent compensation FAILED; core must be reloaded"); + return FlowWriteRejected::CompensationFailed { + class, + diagnostic: format!("mid-apply error: {error:#}; revert_to error: {revert_error}"), + }; + } + doc.set_next_commit_origin("repair"); + doc.set_next_commit_message("flow-intent-compensation-inverse"); + doc.commit(); + // One atomic publish payload covering partial + inverse (spec I-10c). + core.queue_local_update_publish(vv_before); + if let Err(refresh_error) = core.refresh_all() { + tracing::error!(class, %refresh_error, "flow refresh after compensation failed; core must be reloaded"); + return FlowWriteRejected::CompensationFailed { + class, + diagnostic: format!("mid-apply error: {error:#}; post-compensation refresh error: {refresh_error:#}"), + }; + } + FlowWriteRejected::CompensatedFailure { + class, + diagnostic: format!("{error:#}"), + } +} diff --git a/crates/flowstate-collab/src/flow/flow_io.rs b/crates/flowstate-collab/src/flow/flow_io.rs new file mode 100644 index 00000000..f94ece9f --- /dev/null +++ b/crates/flowstate-collab/src/flow/flow_io.rs @@ -0,0 +1,289 @@ +//! `FlowIoService` — the background flow I/O service, sibling of +//! `DocIoService` (Loro-first spec §3/§6, applied to the flow gate). +//! +//! Shares the `WriteGate` with the UI thread's `FlowDocHandle` +//! and acquires it per doc-touching operation. Duties: remote import chunks +//! (coalesced under one gate hold with the §6.4 byte budget), publish-queue +//! drains, update/snapshot exports (fork under the gate, export off it), and +//! `.fl0` persistence (atomic save + recovery-file bytes). + +use std::io; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context as _, Result}; +use async_channel::{Receiver, Sender}; +use flowstate_flow::projection::FlowBoardProjection; +use loro::{ExportMode, VersionVector}; + +use super::runtime::{FlowPublishEvent, FlowRuntime}; +use crate::io_util::{gate_call, send_reply}; +use crate::local_write::{GateHolder, WriteGate}; + +const IO_REQUEST_CHANNEL_CAPACITY: usize = 256; +/// Maximum queued import blobs coalesced into one gate hold (spec §6.4). +const IMPORT_COALESCE_MAX: usize = 16; +/// §A14.1.1 byte budget: never glue chunked mass-op slices back into one hold. +const IMPORT_COALESCE_BYTE_BUDGET: usize = 64 * 1024; + +type ReplySender = Sender>; + +pub enum FlowIoRequest { + ImportRemoteUpdate { + bytes: Vec, + reply: ReplySender<()>, + }, + PumpPublish { + reply: ReplySender>, + }, + BoardSnapshot { + reply: ReplySender, + }, + OplogVersionVector { + reply: ReplySender>, + }, + ExportUpdatesFor { + remote_vv: Vec, + reply: ReplySender>, + }, + SnapshotBytes { + reply: ReplySender>, + }, + /// Atomic `.fl0` save (encode v2 + write-then-rename). + SaveTo { + path: PathBuf, + reply: ReplySender<()>, + }, + /// Encoded `.fl0` bytes (recovery files for pathless joined tabs). + EncodeBytes { + reply: ReplySender>, + }, +} + +fn request_kind(request: &FlowIoRequest) -> &'static str { + match request { + FlowIoRequest::ImportRemoteUpdate { .. } => "import-remote-update", + FlowIoRequest::PumpPublish { .. } => "pump-publish", + FlowIoRequest::BoardSnapshot { .. } => "board-snapshot", + FlowIoRequest::OplogVersionVector { .. } => "oplog-version-vector", + FlowIoRequest::ExportUpdatesFor { .. } => "export-updates-for", + FlowIoRequest::SnapshotBytes { .. } => "snapshot-bytes", + FlowIoRequest::SaveTo { .. } => "save-to", + FlowIoRequest::EncodeBytes { .. } => "encode-bytes", + } +} + +/// Marker type for the service thread (spawned via [`FlowIoHandle::spawn`]). +pub struct FlowIoService; + +/// Cloneable handle to the flow I/O service thread. +#[derive(Clone)] +pub struct FlowIoHandle { + requests: Sender, +} + +impl FlowIoHandle { + pub fn spawn(core: Arc>) -> io::Result { + let (sender, receiver) = async_channel::bounded(IO_REQUEST_CHANNEL_CAPACITY); + std::thread::Builder::new() + .name("flowstate-flow-io".to_string()) + .spawn(move || io_loop(&core, &receiver))?; + Ok(Self { requests: sender }) + } + + async fn request(&self, build: impl FnOnce(ReplySender) -> FlowIoRequest) -> Result { + let (reply, response) = async_channel::bounded(1); + self + .requests + .send(build(reply)) + .await + .map_err(|_| anyhow::anyhow!("flow I/O service stopped"))?; + response + .recv() + .await + .map_err(|_| anyhow::anyhow!("flow I/O service dropped the reply"))? + } + + pub async fn import_remote_update(&self, bytes: Vec) -> Result<()> { + self + .request(|reply| FlowIoRequest::ImportRemoteUpdate { bytes, reply }) + .await + } + + /// Drain committed-but-unpublished local events. + pub async fn pump_publish(&self) -> Result> { + self + .request(|reply| FlowIoRequest::PumpPublish { reply }) + .await + } + + pub async fn board_snapshot(&self) -> Result { + self + .request(|reply| FlowIoRequest::BoardSnapshot { reply }) + .await + } + + pub async fn oplog_version_vector(&self) -> Result> { + self + .request(|reply| FlowIoRequest::OplogVersionVector { reply }) + .await + } + + pub async fn export_updates_for(&self, remote_vv: Vec) -> Result> { + self + .request(|reply| FlowIoRequest::ExportUpdatesFor { remote_vv, reply }) + .await + } + + pub async fn snapshot_bytes(&self) -> Result> { + self + .request(|reply| FlowIoRequest::SnapshotBytes { reply }) + .await + } + + pub async fn save_to(&self, path: PathBuf) -> Result<()> { + self + .request(|reply| FlowIoRequest::SaveTo { path, reply }) + .await + } + + pub async fn encode_bytes(&self) -> Result> { + self + .request(|reply| FlowIoRequest::EncodeBytes { reply }) + .await + } +} + +fn io_loop(core: &Arc>, receiver: &Receiver) { + let mut deferred: Vec = Vec::new(); + loop { + let request = if let Some(request) = deferred.pop() { + request + } else { + match receiver.recv_blocking() { + Ok(request) => request, + Err(_) => break, + } + }; + let kind = request_kind(&request); + let started = std::time::Instant::now(); + match request { + FlowIoRequest::ImportRemoteUpdate { bytes, reply } => { + // Spec §6.4: coalesce immediately-available import blobs into ONE gate + // acquisition, respecting the byte budget; non-import requests popped + // while draining are deferred, order preserved among themselves. + let mut coalesced_bytes = bytes.len(); + let mut chunk: Vec<(Vec, ReplySender<()>)> = vec![(bytes, reply)]; + while chunk.len() < IMPORT_COALESCE_MAX && coalesced_bytes < IMPORT_COALESCE_BYTE_BUDGET { + match receiver.try_recv() { + Ok(FlowIoRequest::ImportRemoteUpdate { bytes, reply }) => { + coalesced_bytes += bytes.len(); + chunk.push((bytes, reply)); + }, + Ok(other) => { + deferred.push(other); + break; + }, + Err(_) => break, + } + } + match core.lock(GateHolder::ImportChunk) { + Ok(mut guard) => { + // §act-five P1.A second drain: fold in blobs that arrived while + // this thread was blocked on the gate — no added latency. + while chunk.len() < IMPORT_COALESCE_MAX && coalesced_bytes < IMPORT_COALESCE_BYTE_BUDGET { + match receiver.try_recv() { + Ok(FlowIoRequest::ImportRemoteUpdate { bytes, reply }) => { + coalesced_bytes += bytes.len(); + chunk.push((bytes, reply)); + }, + Ok(other) => { + deferred.push(other); + break; + }, + Err(_) => break, + } + } + let blobs: Vec<&[u8]> = chunk.iter().map(|(bytes, _)| bytes.as_slice()).collect(); + match guard.import_remote_updates(&blobs) { + Ok(()) => { + for (_, reply) in &chunk { + send_reply(reply, Ok(())); + } + }, + Err(error) => { + let error = format!("{error:#}"); + for (_, reply) in &chunk { + send_reply(reply, Err(anyhow::anyhow!("batched flow import failed: {error}"))); + } + }, + } + if chunk.len() > 1 { + tracing::debug!(coalesced = chunk.len(), "coalesced flow import chunk under one gate hold"); + } + }, + Err(poisoned) => { + for (_, reply) in chunk { + send_reply(&reply, Err(anyhow::anyhow!(poisoned))); + } + }, + } + }, + FlowIoRequest::PumpPublish { reply } => { + let result = core + .lock(GateHolder::ExportUpdates) + .map(|mut guard| guard.take_pending_publish()) + .map_err(|poisoned| anyhow::anyhow!(poisoned)); + send_reply(&reply, result); + }, + FlowIoRequest::BoardSnapshot { reply } => send_reply( + &reply, + gate_call(core, GateHolder::DocumentService, |runtime| Ok(runtime.board_ref().clone())), + ), + FlowIoRequest::OplogVersionVector { reply } => send_reply( + &reply, + gate_call(core, GateHolder::ExportUpdates, |runtime| Ok(runtime.oplog_version_vector().encode())), + ), + FlowIoRequest::ExportUpdatesFor { remote_vv, reply } => { + let result = VersionVector::decode(&remote_vv) + .context("decoding remote Loro version vector") + .and_then(|vv| gate_call(core, GateHolder::ExportUpdates, |runtime| runtime.export_updates_for(&vv))); + send_reply(&reply, result); + }, + FlowIoRequest::SnapshotBytes { reply } => { + // I-9a long-export rule: fork under the gate (brief), export off it. + let forked = gate_call(core, GateHolder::ExportFork, |runtime| Ok(runtime.doc().fork())); + let result = forked.and_then(|fork| { + fork + .export(ExportMode::Snapshot) + .context("exporting flow snapshot from fork") + }); + send_reply(&reply, result); + }, + FlowIoRequest::SaveTo { path, reply } => { + let forked = gate_call(core, GateHolder::DocumentService, |runtime| Ok(runtime.doc().fork())); + let result = forked.and_then(|fork| { + let snapshot = fork + .export(ExportMode::Snapshot) + .context("exporting flow snapshot for save")?; + flowstate_flow::write_fl0(&path, &snapshot) + }); + send_reply(&reply, result); + }, + FlowIoRequest::EncodeBytes { reply } => { + let forked = gate_call(core, GateHolder::DocumentService, |runtime| Ok(runtime.doc().fork())); + let result = forked.and_then(|fork| { + let snapshot = fork + .export(ExportMode::Snapshot) + .context("exporting flow snapshot for recovery bytes")?; + Ok(flowstate_flow::encode_fl0_snapshot(&snapshot)) + }); + send_reply(&reply, result); + }, + } + let elapsed = started.elapsed(); + if elapsed > std::time::Duration::from_millis(250) { + tracing::debug!(kind, ?elapsed, "slow flow I/O request"); + } + } +} diff --git a/crates/flowstate-collab/src/flow/handle.rs b/crates/flowstate-collab/src/flow/handle.rs new file mode 100644 index 00000000..cae6da07 --- /dev/null +++ b/crates/flowstate-collab/src/flow/handle.rs @@ -0,0 +1,273 @@ +//! `FlowDocHandle` — the ONE local write API for a flow document (invariant 5: +//! solo and collaborative tabs hold this identical handle; collaboration only +//! adds the flow I/O service draining the publish queue behind the same gate). + +use std::sync::{Arc, Mutex}; + +use flowstate_flow::format::{CellId, FlowFormat}; +use flowstate_flow::intents::FlowIntent; +use flowstate_flow::projection::FlowBoardProjection; +use gpui_flowtext::DocumentProjection; +use uuid::Uuid; + +use super::cell_authority::FlowCellAuthority; +use super::commit::apply_flow_intent; +use super::runtime::{FlowRuntime, FlowStreamItem, FlowUndoMeta, FlowUndoOutcome, FlowWriteOutcome, FlowWriteRejected}; +use crate::local_write::{GateHolder, WriteGate}; + +pub struct FlowDocHandle { + core: Arc>, + /// Board context stamped onto each committed intent's undo item — kept + /// current by the editor (focus/sheet changes), read by `apply`. + undo_context: Mutex, +} + +impl FlowDocHandle { + /// Wrap a runtime. The returned gate is shared with the flow I/O service so + /// imports/exports serialize with local writes. + #[must_use] + pub fn new(core: FlowRuntime) -> (Arc, Arc>) { + let gate = Arc::new(WriteGate::new(core)); + (Self::attach(Arc::clone(&gate)), gate) + } + + /// Attach to an existing shared core (join handoff). + #[must_use] + pub fn attach(core: Arc>) -> Arc { + Arc::new(Self { + core, + undo_context: Mutex::new(FlowUndoMeta::default()), + }) + } + + /// Fresh solo document. + pub fn new_document(format: &FlowFormat) -> anyhow::Result<(Arc, Arc>)> { + Ok(Self::new(FlowRuntime::new(format)?)) + } + + #[must_use] + pub fn gate(&self) -> Arc> { + Arc::clone(&self.core) + } + + fn lock(&self, holder: GateHolder) -> Result, FlowWriteRejected> { + self + .core + .lock(holder) + .map_err(|_| FlowWriteRejected::GatePoisoned) + } + + /// Update the board context riding future undo items (focus restoration). + pub fn set_undo_context(&self, meta: FlowUndoMeta) { + if let Ok(mut context) = self.undo_context.lock() { + *context = meta; + } + } + + pub(crate) fn undo_context(&self) -> FlowUndoMeta { + self + .undo_context + .lock() + .map(|context| context.clone()) + .unwrap_or_default() + } + + /// Apply one flow intent: the whole resolve → mutate → commit → derive path + /// under one gate hold; the returned outcome IS the committed state. + pub fn apply(&self, intent: FlowIntent) -> Result { + let meta = self.undo_context(); + let mut guard = self.lock(GateHolder::LocalIntent)?; + guard.set_pending_undo_meta(Some(meta)); + let outcome = apply_flow_intent(&mut guard, &intent); + guard.set_pending_undo_meta(None); + outcome + } + + pub fn document_id(&self) -> Result { + Ok(self.lock(GateHolder::LocalIntent)?.document_id()) + } + + /// Clone the canonical board (editor attach; afterwards the editor advances + /// its copy exclusively via the ordered board stream). + pub fn board_projection(&self) -> Result { + Ok(self.lock(GateHolder::LocalIntent)?.board_ref().clone()) + } + + pub fn drain_board_stream(&self) -> Result, FlowWriteRejected> { + Ok(self.lock(GateHolder::LocalIntent)?.take_board_stream()) + } + + /// Materialize + start streaming one cell (editor attach path). + pub fn open_cell(&self, cell: CellId) -> Result { + self + .lock(GateHolder::LocalIntent)? + .open_cell(cell) + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) + } + + pub fn close_cell(&self, cell: CellId) -> Result<(), FlowWriteRejected> { + self.lock(GateHolder::LocalIntent)?.close_cell(cell); + Ok(()) + } + + /// The per-cell write authority injected into that cell's `RichTextEditor` + /// (`RichTextEditor::set_write_authority`). + #[must_use] + pub fn cell_authority(self: &Arc, cell: CellId) -> Arc { + Arc::new(FlowCellAuthority::new(Arc::clone(self), cell)) + } + + // ---- Undo (spec §10) ------------------------------------------------------- + + pub fn undo(&self) -> Result { + self + .lock(GateHolder::UndoRedo)? + .undo() + .map_err(|error| FlowWriteRejected::CompensatedFailure { + class: "flow-undo", + diagnostic: format!("{error:#}"), + }) + } + + pub fn redo(&self) -> Result { + self + .lock(GateHolder::UndoRedo)? + .redo() + .map_err(|error| FlowWriteRejected::CompensatedFailure { + class: "flow-redo", + diagnostic: format!("{error:#}"), + }) + } + + pub fn undo_group_start(&self) -> Result { + Ok(self.lock(GateHolder::UndoRedo)?.undo_group_start()) + } + + pub fn undo_group_end(&self) -> Result<(), FlowWriteRejected> { + self.lock(GateHolder::UndoRedo)?.undo_group_end(); + Ok(()) + } + + pub fn can_undo(&self) -> Result { + Ok(self.lock(GateHolder::UndoRedo)?.can_undo()) + } + + pub fn can_redo(&self) -> Result { + Ok(self.lock(GateHolder::UndoRedo)?.can_redo()) + } + + /// Read-only cell materialization for render previews — unlike + /// [`Self::open_cell`], no stream/cache entry is created. + pub fn cell_preview(&self, cell: CellId) -> Result { + self.with_runtime(GateHolder::LocalIntent, |runtime| { + flowstate_flow::loro_projection::materialize_cell_projection( + runtime.doc(), + cell, + flowstate_document::DocumentTheme::clone(&flowstate_document::flowstate_document_theme()), + ) + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) + })? + } + + /// Whole-doc Loro snapshot under a brief gate hold (solo save path; the + /// collaboration save goes through the flow I/O service's fork-off-gate). + pub fn snapshot(&self) -> Result, FlowWriteRejected> { + self.with_runtime(GateHolder::DocumentService, |runtime| { + runtime + .snapshot() + .map_err(|error| FlowWriteRejected::StructureViolation(format!("{error:#}"))) + })? + } + + // ---- Presence (spec Part C, step 11) ---------------------------------------- + + /// Encode a cell-local editor selection as presence cursor bytes (exact Loro + /// cursors — no offset guessing). Advisory: presence rides its own channel, + /// so unlike undo-context capture there is no editor-frontier requirement. + pub fn presence_selection(&self, cell: CellId, selection: &gpui_flowtext::EditorSelection) -> Option { + use crate::presence::{PresenceSelection, SelectionAffinity, SelectionEndpoint, VisualGravity}; + let selection = selection.clone(); + self + .with_runtime(GateHolder::Presence, move |runtime| { + let ctx = super::cell_text::CellTextContext::resolve(runtime.doc(), cell).ok()?; + let endpoint = + |offset: gpui_flowtext::DocumentOffset, affinity: SelectionAffinity, gravity: VisualGravity| -> Option { + let unicode = ctx.unicode_for_offset(offset)?; + let cursor = crate::crdt_runtime::cursor_for_boundary(&ctx.text, unicode, affinity)?; + Some(SelectionEndpoint { + cursor: cursor.encode(), + affinity, + visual_gravity: gravity, + }) + }; + Some(PresenceSelection { + anchor: endpoint( + selection.anchor, + SelectionAffinity::from(selection.anchor_affinity), + VisualGravity::from(selection.anchor_gravity), + )?, + head: endpoint( + selection.head, + SelectionAffinity::from(selection.head_affinity), + VisualGravity::from(selection.head_gravity), + )?, + direction: selection_direction(selection.anchor, selection.head), + }) + }) + .ok() + .flatten() + } + + /// Resolve a remote peer's presence caret against THIS cell's current text: + /// `(head, anchor)` projection offsets, or `None` when the cursors belong to + /// another container (foreign-cell cursors fail safely) or no longer resolve. + pub fn resolve_presence_selection( + &self, + cell: CellId, + selection: &crate::presence::PresenceSelection, + ) -> Option<(gpui_flowtext::DocumentOffset, gpui_flowtext::DocumentOffset)> { + use loro::{ContainerTrait as _, cursor::Cursor}; + let head_cursor = selection.head.cursor.clone(); + let anchor_cursor = selection.anchor.cursor.clone(); + self + .with_runtime(GateHolder::Presence, move |runtime| { + let ctx = super::cell_text::CellTextContext::resolve(runtime.doc(), cell).ok()?; + let resolve = |encoded: &[u8]| { + let cursor = Cursor::decode(encoded).ok()?; + if cursor.container != ctx.text.id() { + return None; + } + let position = runtime.doc().get_cursor_pos(&cursor).ok()?; + ctx.offset_for_unicode(position.current.pos) + }; + Some((resolve(&head_cursor)?, resolve(&anchor_cursor)?)) + }) + .ok() + .flatten() + } + + /// Test/tooling support: run a closure against the gate-held runtime (the + /// flow analog of holding a test gate guard on the .db8 core). + pub fn with_test_runtime(&self, call: impl FnOnce(&mut FlowRuntime) -> T) -> T { + let mut guard = self + .core + .lock(GateHolder::DocumentService) + .expect("flow write gate healthy"); + call(&mut guard) + } + + // ---- Gate-held reads shared with the cell authority ------------------------ + + pub(crate) fn with_runtime(&self, holder: GateHolder, read: impl FnOnce(&mut FlowRuntime) -> T) -> Result { + let mut guard = self.lock(holder)?; + Ok(read(&mut guard)) + } +} + +fn selection_direction(anchor: gpui_flowtext::DocumentOffset, head: gpui_flowtext::DocumentOffset) -> crate::presence::SelectionDirection { + match anchor.cmp(&head) { + std::cmp::Ordering::Less => crate::presence::SelectionDirection::Forward, + std::cmp::Ordering::Greater => crate::presence::SelectionDirection::Backward, + std::cmp::Ordering::Equal => crate::presence::SelectionDirection::None, + } +} diff --git a/crates/flowstate-collab/src/flow/mod.rs b/crates/flowstate-collab/src/flow/mod.rs new file mode 100644 index 00000000..cc19948e --- /dev/null +++ b/crates/flowstate-collab/src/flow/mod.rs @@ -0,0 +1,30 @@ +//! The .fl0 CRDT-first write path (flow spec Part B) — the flow mirror of the +//! .db8 `local_write`/`crdt_runtime` split, at board scale. +//! +//! One law, reused: every local mutation is a typed [`flowstate_flow::FlowIntent`] +//! applied through [`FlowDocHandle`] → `WriteGate` (the SAME +//! generic gate the .db8 core uses) → resolve → mutate → ONE commit (origin +//! `"local"`, message = intent class) → in-place projection derivation → the +//! ordered streams → the publish queue. Remote traffic reaches the same doc +//! only through the flow I/O service, one import chunk per gate hold. +//! +//! Streams (single-ordered-stream law per consumer): +//! * ONE board stream (`FlowStreamItem::Board`, Replace-per-change — board +//! metadata is ~100 bytes/cell with shared `Arc` summaries), drained by +//! the `FlowEditor`. +//! * ONE stream per OPEN cell (`ProjectionStreamItem`, whole-cell `Replace` — +//! cells are tiny, the .db8 body patch synthesis is deliberately not +//! ported), drained by that cell's `RichTextEditor` via its +//! [`cell_authority::FlowCellAuthority`]. + +pub mod cell_authority; +pub mod cell_text; +pub mod commit; +pub mod flow_io; +pub mod handle; +pub mod runtime; + +pub use cell_authority::FlowCellAuthority; +pub use flow_io::{FlowIoHandle, FlowIoService}; +pub use handle::FlowDocHandle; +pub use runtime::{FlowPublishEvent, FlowRuntime, FlowStreamItem, FlowUndoMeta, FlowUndoOutcome, FlowWriteOutcome, FlowWriteRejected}; diff --git a/crates/flowstate-collab/src/flow/runtime.rs b/crates/flowstate-collab/src/flow/runtime.rs new file mode 100644 index 00000000..00b9b443 --- /dev/null +++ b/crates/flowstate-collab/src/flow/runtime.rs @@ -0,0 +1,611 @@ +//! `FlowRuntime` — the gate-protected flow document core (flow spec Part B). +//! +//! Owns the `LoroDoc`, the maintained [`FlowBoardProjection`], the per-open-cell +//! materialization cache, the ordered board/cell streams, the publish queue, +//! and the Loro `UndoManager` (origins `"remote"`/`"repair"` excluded, `"meta"` +//! inert — the .db8 configuration verbatim). Every method assumes the caller +//! holds the `WriteGate`. + +use std::sync::{Arc, Mutex}; + +use anyhow::{Context as _, Result}; +use flowstate_flow::format::{CellId, FlowFormat, SheetId}; +use flowstate_flow::loro_projection::{materialize_board, materialize_cell_rows, summary_from_rows}; +use flowstate_flow::projection::{CellSummary, FlowBoardProjection}; +use flowstate_flow::{FlowDefect, loro_schema}; +use gpui_flowtext::{DocumentProjection, InputBlock, ParagraphId, ProjectionStreamItem}; +use loro::{ExportMode, InnerUndoManager as UndoManager, LoroDoc, LoroValue, UndoItemMeta, VersionVector, cursor::Cursor}; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// One item of the ordered BOARD stream: Replace-per-change (board metadata is +/// small; summaries are shared `Arc`s). +#[derive(Clone, Debug)] +pub enum FlowStreamItem { + Board(Box), +} + +/// Publish-side events buffered under the gate for the flow I/O service (the +/// flow mirror of the .db8 `RuntimeEvent` publish subset). +#[derive(Clone, Debug)] +pub enum FlowPublishEvent { + LocalUpdate { + bytes: Vec, + frontier: Vec, + version_vector: Vec, + }, + RemoteUpdateApplied { + frontier: Vec, + version_vector: Vec, + }, +} + +/// Board-context metadata riding each undo item (`FlowUndoMeta`, spec Part B): +/// restores sheet focus, cell focus, and the cell-text selection through the +/// undo outcome. Cursors are Loro cursor bytes, actively transformed by the +/// `UndoManager` while the item sits on the stack. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct FlowUndoMeta { + pub active_sheet: Option, + pub focused_cell: Option, + pub head_cursor: Option>, + pub anchor_cursor: Option>, +} + +/// Outcome of an undo/redo (the projection change rides the ordered streams). +#[derive(Clone, Debug, Default)] +pub struct FlowUndoOutcome { + pub applied: bool, + pub meta: Option, +} + +/// The synchronous result of a committed flow intent. +#[derive(Clone, Debug)] +pub struct FlowWriteOutcome { + /// `false` when the intent resolved to a no-op (nothing committed). + pub changed: bool, + pub frontier: Vec, + pub version_vector: Vec, + /// Cursor-backed caret after a `CellText` intent. + pub selection_after: Option, +} + +/// Why a flow intent was rejected before mutation (I-15: rejection means the +/// doc was not touched; compensated failures mean it was restored). +#[derive(Clone, Debug)] +pub enum FlowWriteRejected { + UnknownSheet(SheetId), + UnknownCell(CellId), + UnresolvedParagraph(ParagraphId), + UnresolvedCursor, + EmptyIntent, + StructureViolation(String), + GatePoisoned, + CompensatedFailure { class: &'static str, diagnostic: String }, + CompensationFailed { class: &'static str, diagnostic: String }, +} + +impl std::fmt::Display for FlowWriteRejected { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownSheet(sheet) => write!(f, "sheet {sheet} did not resolve against canonical flow state"), + Self::UnknownCell(cell) => write!(f, "cell {cell} did not resolve against canonical flow state"), + Self::UnresolvedParagraph(id) => write!(f, "cell paragraph identity {} did not resolve", id.0), + Self::UnresolvedCursor => f.write_str("supplied cursor could not be resolved against the cell's text"), + Self::EmptyIntent => f.write_str("flow intent is a no-op"), + Self::StructureViolation(reason) => write!(f, "flow intent violates board structure: {reason}"), + Self::GatePoisoned => f.write_str("flow write gate poisoned; reload from persisted state"), + Self::CompensatedFailure { class, diagnostic } => { + write!(f, "flow intent '{class}' failed mid-apply and was compensated back: {diagnostic}") + }, + Self::CompensationFailed { class, diagnostic } => { + write!(f, "flow intent '{class}' failed mid-apply AND compensation failed: {diagnostic}") + }, + } + } +} + +impl std::error::Error for FlowWriteRejected {} + +/// Cached materialization for one OPEN cell (an attached editor): the change +/// detector for import-driven refreshes and the resolution basis for text +/// anchors. +pub(crate) struct CellEntry { + pub(crate) blocks: Vec, + pub(crate) paragraph_ids: Vec, +} + +#[derive(Default)] +pub(crate) struct FlowUndoMetaState { + /// Set by the handle before each intent; captured by `on_push`. + pub(crate) pending: Option, + /// Set by `on_pop` when an undo/redo restores an item's metadata. + pub(crate) restored: Option, +} + +pub struct FlowRuntime { + doc: LoroDoc, + board: FlowBoardProjection, + document_id: Uuid, + pub(crate) cells: FxHashMap, + board_stream: Vec, + cell_streams: FxHashMap>, + pending_publish: Vec, + pub(crate) undo: UndoManager, + undo_meta: Arc>, + /// Latest normalization defects (repair-pass input; capped by stable key). + pub(crate) defects: Vec, + /// Remaining import-side order-canonicalization repair passes (loop guard). + order_repairs_remaining: u32, + /// Batched-import calls served (one per gate hold) — the §6.4 coalescing + /// counter the pump tests assert against. + import_batches_served: u64, +} + +impl FlowRuntime { + /// Fresh flow document (solo new-tab path). + pub fn new(format: &FlowFormat) -> Result { + let doc = LoroDoc::new(); + let document_id = loro_schema::init_flow_document(&doc, format)?; + doc.set_next_commit_origin("meta"); + doc.set_next_commit_message("flow-init"); + doc.commit(); + Self::from_doc(doc, document_id) + } + + /// Attach to an imported snapshot (open / join). Validates the v2 schema — + /// the wrong-kind-session and wrong-schema defense. + pub fn from_snapshot(snapshot: &[u8]) -> Result { + let doc = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&doc); + doc.import(snapshot).context("importing flow snapshot")?; + let version = loro_schema::read_schema_version(&doc); + anyhow::ensure!( + version == Some(loro_schema::FLOW_SCHEMA_VERSION), + "flow snapshot has schema version {version:?}, expected {} — not a Flowstate .fl0 v2 document", + loro_schema::FLOW_SCHEMA_VERSION + ); + let document_id = loro_schema::read_document_id(&doc).context("flow snapshot has no document id")?; + Self::from_doc(doc, document_id) + } + + fn from_doc(doc: LoroDoc, document_id: Uuid) -> Result { + let (board, defects) = materialize_board(&doc)?; + let undo = UndoManager::new(doc.inner()); + // Spec §10 (D6) verbatim: undo units are explicit group boundaries. + undo.set_merge_interval(0); + undo.set_max_undo_steps(300); + undo.add_exclude_origin_prefix("remote"); + undo.add_exclude_origin_prefix("repair"); + undo.add_inert_origin_prefix("meta"); + let undo_meta = Arc::new(Mutex::new(FlowUndoMetaState::default())); + install_undo_meta_callbacks(&undo, &undo_meta); + Ok(Self { + doc, + board, + document_id, + cells: FxHashMap::default(), + board_stream: Vec::new(), + cell_streams: FxHashMap::default(), + pending_publish: Vec::new(), + undo, + undo_meta, + defects, + order_repairs_remaining: 64, + import_batches_served: 0, + }) + } + + pub fn doc(&self) -> &LoroDoc { + &self.doc + } + + #[must_use] + pub fn document_id(&self) -> Uuid { + self.document_id + } + + #[must_use] + pub fn board_ref(&self) -> &FlowBoardProjection { + &self.board + } + + pub(crate) fn board_mut(&mut self) -> &mut FlowBoardProjection { + &mut self.board + } + + pub fn frontier(&self) -> Vec { + self.doc.state_frontiers().encode() + } + + pub fn oplog_version_vector(&self) -> VersionVector { + self.doc.oplog_vv() + } + + pub fn export_updates_for(&self, version: &VersionVector) -> Result> { + Ok(self.doc.export(ExportMode::updates(version))?) + } + + pub fn snapshot(&self) -> Result> { + Ok(self.doc.export(ExportMode::Snapshot)?) + } + + // ---- Streams + publish queue --------------------------------------------- + + pub(crate) fn push_board_stream(&mut self) { + self + .board_stream + .push(FlowStreamItem::Board(Box::new(self.board.clone()))); + } + + pub fn take_board_stream(&mut self) -> Vec { + std::mem::take(&mut self.board_stream) + } + + pub fn take_cell_stream(&mut self, cell: CellId) -> Vec { + self + .cell_streams + .get_mut(&cell) + .map(std::mem::take) + .unwrap_or_default() + } + + pub fn queue_publish(&mut self, events: Vec) { + self.pending_publish.extend(events); + } + + pub fn take_pending_publish(&mut self) -> Vec { + std::mem::take(&mut self.pending_publish) + } + + pub fn has_pending_publish(&self) -> bool { + !self.pending_publish.is_empty() + } + + /// Export the covering update since `vv_before` and queue it for publish. + pub(crate) fn queue_local_update_publish(&mut self, vv_before: &VersionVector) { + match self.doc.export(ExportMode::updates(vv_before)) { + Ok(bytes) if !bytes.is_empty() => { + let event = FlowPublishEvent::LocalUpdate { + bytes, + frontier: self.frontier(), + version_vector: self.doc.state_vv().encode(), + }; + self.pending_publish.push(event); + }, + Ok(_) => {}, + Err(error) => { + // Anti-entropy recovers the update; the committed intent stands. + tracing::error!(%error, "exporting committed flow update failed; anti-entropy must recover it"); + }, + } + } + + // ---- Open-cell lifecycle --------------------------------------------------- + + /// Materialize (and start streaming) one cell's rich-text projection — the + /// editor-attach path. Idempotent per cell. + pub fn open_cell(&mut self, cell: CellId) -> Result { + let rows = materialize_cell_rows(&self.doc, cell)?; + let projection = flowstate_flow::loro_projection::document_from_rows( + &self.doc, + flowstate_document::RegionRows { + blocks: rows.blocks.clone(), + paragraph_ids: rows.paragraph_ids.clone(), + block_ids: rows.block_ids.clone(), + defects: Vec::new(), + }, + flowstate_document::DocumentTheme::clone(&flowstate_document::flowstate_document_theme()), + ); + self.cells.insert( + cell, + CellEntry { + blocks: rows.blocks, + paragraph_ids: rows.paragraph_ids, + }, + ); + self.cell_streams.entry(cell).or_default(); + Ok(projection) + } + + /// Drop the cell's stream + cache (editor detached). + pub fn close_cell(&mut self, cell: CellId) { + self.cells.remove(&cell); + self.cell_streams.remove(&cell); + } + + #[must_use] + pub fn is_cell_open(&self, cell: CellId) -> bool { + self.cells.contains_key(&cell) + } + + /// Rematerialize one OPEN cell; on change, update the cache, push a whole- + /// cell `Replace` onto its stream, and refresh its board summary in place. + /// Returns whether the cell's SUMMARY changed (the board-stream trigger). + pub(crate) fn refresh_cell(&mut self, cell: CellId) -> Result { + let Some(entry) = self.cells.get(&cell) else { + // Not open: only the summary can be stale. + return self.refresh_board_summary(cell); + }; + let rows = materialize_cell_rows(&self.doc, cell)?; + if rows.blocks == entry.blocks && rows.paragraph_ids == entry.paragraph_ids { + return Ok(false); + } + let summary_changed = self.set_board_summary(cell, summary_from_rows(&rows.blocks)); + let projection = flowstate_flow::loro_projection::document_from_rows( + &self.doc, + flowstate_document::RegionRows { + blocks: rows.blocks.clone(), + paragraph_ids: rows.paragraph_ids.clone(), + block_ids: rows.block_ids.clone(), + defects: Vec::new(), + }, + flowstate_document::DocumentTheme::clone(&flowstate_document::flowstate_document_theme()), + ); + self.cells.insert( + cell, + CellEntry { + blocks: rows.blocks, + paragraph_ids: rows.paragraph_ids, + }, + ); + self + .cell_streams + .entry(cell) + .or_default() + .push(ProjectionStreamItem::Replace(Box::new(projection))); + Ok(summary_changed) + } + + /// Refresh only the board-side summary of a (possibly closed) cell. + pub(crate) fn refresh_board_summary(&mut self, cell: CellId) -> Result { + let rows = materialize_cell_rows(&self.doc, cell)?; + Ok(self.set_board_summary(cell, summary_from_rows(&rows.blocks))) + } + + fn set_board_summary(&mut self, cell: CellId, summary: CellSummary) -> bool { + for sheet in &mut self.board.sheets { + if let Some(found) = sheet + .cells + .iter_mut() + .find(|candidate| candidate.id == cell) + { + if found.summary == summary { + return false; + } + found.summary = summary; + return true; + } + } + false + } + + // ---- Whole-board refresh (imports, undo, compensation) -------------------- + + /// Rebuild the board from canonical state, refresh every open cell, and push + /// Replace items on every affected stream. The import/undo derivation path. + pub(crate) fn refresh_all(&mut self) -> Result<()> { + let (board, defects) = materialize_board(&self.doc)?; + self.defects = defects; + self.board = board; + self.push_board_stream(); + let open: Vec = self.cells.keys().copied().collect(); + for cell in open { + let live = self.board.cell(cell).is_some(); + if live { + // `refresh_cell` re-derives the summary; the fresh board already has + // it, so only the cell stream side effect matters here. + let _ = self.refresh_cell(cell)?; + } else { + // Cell deleted remotely: the board Replace already tells the editor; + // drop the dead stream + cache. + self.close_cell(cell); + } + } + Ok(()) + } + + /// Debug audit (spec §7 shape): the in-place board maintained by the intent + /// executors must equal an independent fresh materialization. + #[cfg(debug_assertions)] + pub(crate) fn audit_board_against_rebuild(&self, class: &str) { + match materialize_board(&self.doc) { + Ok((fresh, _)) => { + assert!( + fresh == self.board, + "flow intent '{class}' left the maintained board out of sync with a fresh materialization:\n{}", + board_divergence(&self.board, &fresh) + ); + }, + Err(error) => panic!("flow board audit rematerialization failed after '{class}': {error:#}"), + } + } + + // ---- Remote import --------------------------------------------------------- + + /// Import remote update chunks under ONE gate hold with ONE derivation. + pub fn import_remote_updates(&mut self, chunks: &[&[u8]]) -> Result<()> { + if chunks.is_empty() { + return Ok(()); + } + self.import_batches_served += 1; + let frontier_before = self.doc.state_frontiers(); + for chunk in chunks { + self + .doc + .import(chunk) + .context("importing remote flow update")?; + } + if self.doc.state_frontiers() == frontier_before { + return Ok(()); + } + self.refresh_all()?; + // Capped canonicalization repair (spec Part A): when the merge left the + // raw order lists in a non-canonical interleaving, rewrite them (origin + // "repair") to the canonical linearization every peer already renders. + // Deterministic-target rewrites converge; the cap stops any pathological + // repair ping-pong from spinning. + if self.order_repairs_remaining > 0 { + match super::commit::repair_canonical_orders(self) { + Ok(true) => self.order_repairs_remaining -= 1, + Ok(false) => {}, + Err(error) => tracing::error!(%error, "flow order canonicalization repair failed"), + } + } + let event = FlowPublishEvent::RemoteUpdateApplied { + frontier: self.frontier(), + version_vector: self.doc.state_vv().encode(), + }; + self.pending_publish.push(event); + Ok(()) + } + + // ---- Undo / redo ----------------------------------------------------------- + + pub(crate) fn set_pending_undo_meta(&mut self, meta: Option) { + if let Ok(mut state) = self.undo_meta.lock() { + state.pending = meta; + } + } + + pub fn undo(&mut self) -> Result { + self.undo_redo_step(true) + } + + pub fn redo(&mut self) -> Result { + self.undo_redo_step(false) + } + + fn undo_redo_step(&mut self, undo: bool) -> Result { + let vv_before = self.doc.oplog_vv(); + let applied = if undo { + self.undo.undo().context("applying flow undo")? + } else { + self.undo.redo().context("applying flow redo")? + }; + if !applied { + return Ok(FlowUndoOutcome::default()); + } + self.refresh_all()?; + self.queue_local_update_publish(&vv_before); + let meta = self + .undo_meta + .lock() + .ok() + .and_then(|mut state| state.restored.take()); + Ok(FlowUndoOutcome { applied: true, meta }) + } + + pub fn undo_group_start(&mut self) -> bool { + match self.undo.group_start() { + Ok(()) => true, + Err(error) => { + tracing::debug!(%error, "flow undo group_start declined; re-arming at next boundary"); + false + }, + } + } + + pub fn undo_group_end(&mut self) { + self.undo.group_end(); + } + + #[must_use] + pub fn can_undo(&self) -> bool { + self.undo.can_undo() + } + + #[must_use] + pub fn can_redo(&self) -> bool { + self.undo.can_redo() + } + + #[must_use] + pub fn import_batches_served(&self) -> u64 { + self.import_batches_served + } +} + +/// Debug-audit diagnostics: the first field-level difference between the +/// maintained and freshly-materialized boards. +#[cfg(debug_assertions)] +fn board_divergence(maintained: &FlowBoardProjection, fresh: &FlowBoardProjection) -> String { + if maintained.format != fresh.format { + return "format differs".to_string(); + } + if maintained.sheets.len() != fresh.sheets.len() { + return format!("sheet count {} vs fresh {}", maintained.sheets.len(), fresh.sheets.len()); + } + for (ours, theirs) in maintained.sheets.iter().zip(&fresh.sheets) { + if ours.id != theirs.id || ours.name != theirs.name || ours.sheet_type_id != theirs.sheet_type_id { + return format!("sheet header {:?} vs fresh {:?}", (ours.id, &ours.name), (theirs.id, &theirs.name)); + } + if ours.annotations != theirs.annotations { + return format!("sheet {} annotations differ", ours.id); + } + let our_ids: Vec<_> = ours.cells.iter().map(|cell| cell.id).collect(); + let their_ids: Vec<_> = theirs.cells.iter().map(|cell| cell.id).collect(); + if our_ids != their_ids { + return format!("sheet {} order {our_ids:?} vs fresh {their_ids:?}", ours.id); + } + for (our_cell, their_cell) in ours.cells.iter().zip(&theirs.cells) { + if our_cell != their_cell { + return format!("cell {} maintained {our_cell:?} vs fresh {their_cell:?}", our_cell.id); + } + } + } + "boards differ in an uncompared field".to_string() +} + +fn install_undo_meta_callbacks(undo: &UndoManager, state: &Arc>) { + let push_state = Arc::clone(state); + undo.set_on_push(Some(Box::new(move |_, _, _| { + let mut meta = UndoItemMeta::new(); + if let Ok(state) = push_state.lock() + && let Some(pending) = &state.pending + { + // Cursors ride the first-class `cursors` field so Loro transforms them + // through remote diffs while the item sits on the stack (spec §10). + for encoded in [pending.head_cursor.as_deref(), pending.anchor_cursor.as_deref()] + .into_iter() + .flatten() + { + if let Ok(cursor) = Cursor::decode(encoded) { + meta.add_cursor(&cursor); + } + } + if let Ok(bytes) = postcard::to_allocvec(pending) { + meta.set_value(LoroValue::Binary(bytes.into())); + } + } + meta + }))); + + let pop_state = Arc::clone(state); + undo.set_on_pop(Some(Box::new(move |_, _, meta| { + let LoroValue::Binary(bytes) = meta.value.clone() else { + return; + }; + match postcard::from_bytes::(bytes.as_ref()) { + Ok(mut restored) => { + // Prefer the stack-transformed cursors over the capture-time bytes. + let had_head = restored.head_cursor.is_some(); + let had_anchor = restored.anchor_cursor.is_some(); + let mut transformed = meta.cursors.iter().map(|entry| entry.cursor.encode()); + if had_head && let Some(head) = transformed.next() { + restored.head_cursor = Some(head); + } + if had_anchor && let Some(anchor) = transformed.next() { + restored.anchor_cursor = Some(anchor); + } + if let Ok(mut state) = pop_state.lock() { + state.restored = Some(restored); + } + }, + Err(error) => { + tracing::warn!(%error, "decoding flow undo metadata failed"); + }, + } + }))); +} diff --git a/crates/flowstate-collab/src/io_util.rs b/crates/flowstate-collab/src/io_util.rs new file mode 100644 index 00000000..2f4979a5 --- /dev/null +++ b/crates/flowstate-collab/src/io_util.rs @@ -0,0 +1,22 @@ +//! Tiny helpers shared by the per-kind I/O services (`doc_io` keeps its own +//! battle-tested copies untouched; `flow/flow_io` uses these). + +use std::sync::Arc; + +use anyhow::Result; +use async_channel::Sender; + +use crate::local_write::{GateHolder, WriteGate}; + +pub(crate) fn gate_call(core: &Arc>, holder: GateHolder, call: impl FnOnce(&mut C) -> Result) -> Result { + let mut guard = core + .lock(holder) + .map_err(|poisoned| anyhow::anyhow!(poisoned))?; + call(&mut guard) +} + +pub(crate) fn send_reply(reply: &Sender>, value: Result) { + if let Err(error) = reply.try_send(value) { + tracing::warn!(%error, "I/O reply channel dropped before the reply was sent"); + } +} diff --git a/crates/flowstate-collab/src/lib.rs b/crates/flowstate-collab/src/lib.rs index fefaca9b..ff5f1f7a 100644 --- a/crates/flowstate-collab/src/lib.rs +++ b/crates/flowstate-collab/src/lib.rs @@ -10,17 +10,22 @@ pub mod crdt_runtime; pub mod discovery; pub mod doc_io; pub mod dropbox; +pub mod flow; pub mod identity; pub mod ids; +pub(crate) mod io_util; pub mod local_write; pub mod net; pub mod presence; pub mod proto_direct; pub mod proto_gossip; pub mod self_check; +pub mod sync_io; pub mod ticket; pub use admission::SessionAdmission; pub use ids::{BlobId, SessionId}; pub use proto_direct::DIRECT_ALPN; pub use proto_gossip::PROTOCOL_VERSION; +pub use sync_io::SyncIoHandle; +pub use ticket::DocumentKind; diff --git a/crates/flowstate-collab/src/net/direct.rs b/crates/flowstate-collab/src/net/direct.rs index 924ad585..883c2ee1 100644 --- a/crates/flowstate-collab/src/net/direct.rs +++ b/crates/flowstate-collab/src/net/direct.rs @@ -24,6 +24,7 @@ use crate::{ AssetBytes, DIRECT_ALPN, DirectRequest, DirectResponseHeader, DiscoveryAdmissionGrant, MAX_FRAME_LEN, MAX_PAYLOAD_CHUNK_LEN, MAX_PAYLOAD_LEN, WireCodec, decode_frame, encode_frame, }, + sync_io::SyncIoHandle, }; use super::{PullProgress, auth::SessionAuthRegistry, blobs::BlobOutbox}; @@ -46,15 +47,22 @@ pub struct DirectSessionHandler { /// mid-intent state, so the old shared read-handle path is outlawed), and /// snapshot exports fork under the gate + export off it, so a peer's /// recovery pull still cannot be starved behind local edits. `None` falls - /// back to the session-served request channel. - io: Option, + /// back to the session-served request channel. Kind-agnostic (spec Part C): + /// rich-text and flow sessions serve the identical three transport calls. + io: Option, } impl DirectSessionHandler { #[must_use] - pub fn new(requests: Sender, io: Option) -> Self { + pub fn new(requests: Sender, io: Option) -> Self { Self { requests, io } } + + /// Rich-text convenience constructor (the pre-flow call shape). + #[must_use] + pub fn new_rich_text(requests: Sender, io: Option) -> Self { + Self::new(requests, io.map(SyncIoHandle::RichText)) + } } impl std::fmt::Debug for DirectSessionHandler { @@ -83,6 +91,7 @@ pub struct DirectServeState { struct StandingAccessGrant { document_fingerprint: [u8; 32], title: String, + document: crate::ticket::DocumentKind, identities: HashSet, } @@ -130,6 +139,7 @@ impl DirectServeState { session: SessionId, document_fingerprint: [u8; 32], title: String, + document: crate::ticket::DocumentKind, identities: HashSet, ) { self.standing_access.write().await.insert( @@ -137,6 +147,7 @@ impl DirectServeState { StandingAccessGrant { document_fingerprint, title, + document, identities, }, ); @@ -190,15 +201,15 @@ impl DirectServeState { .get(&session) .and_then(|grant| { (grant.document_fingerprint == request.document_fingerprint && grant.identities.contains(&request.identity)) - .then(|| grant.title.clone()) + .then(|| (grant.title.clone(), grant.document)) }); - let Some(title) = title else { + let Some((title, document)) = title else { return ServeOutcome::Header(DirectResponseHeader::Unauthorized); }; let Some(admission) = self.auth.admission(session) else { return ServeOutcome::Header(DirectResponseHeader::NotAttached); }; - let payload = match postcard::to_stdvec(&DiscoveryAdmissionGrant { admission, title }) { + let payload = match postcard::to_stdvec(&DiscoveryAdmissionGrant { admission, title, document }) { Ok(payload) => payload, Err(_) => return ServeOutcome::Header(DirectResponseHeader::NotFound), }; @@ -545,7 +556,7 @@ where /// is a brief `fork()`; the actual snapshot encode happens off-gate on the I/O /// thread (spec I-9a long-export rule), so a large snapshot neither stalls /// typing nor gets starved behind local edits. -async fn serve_snapshot_via_io(session: SessionId, io: DocIoHandle) -> ServeOutcome { +async fn serve_snapshot_via_io(session: SessionId, io: SyncIoHandle) -> ServeOutcome { match io.snapshot_bytes().await { Ok(bytes) => { tracing::trace!(%session, bytes = bytes.len(), "served collaboration snapshot via the document I/O service"); @@ -561,7 +572,7 @@ async fn serve_snapshot_via_io(session: SessionId, io: DocIoHandle) -> ServeOutc /// Serve an incremental-updates pull through the document I/O service (see /// [`serve_snapshot_via_io`]); the version-vector decode and gate-held export /// happen on the I/O thread. -async fn serve_updates_via_io(session: SessionId, io: DocIoHandle, have_vv: Vec) -> ServeOutcome { +async fn serve_updates_via_io(session: SessionId, io: SyncIoHandle, have_vv: Vec) -> ServeOutcome { match io.export_updates_for(have_vv).await { Ok(bytes) => { tracing::trace!(%session, bytes = bytes.len(), "served collaboration updates via the document I/O service"); diff --git a/crates/flowstate-collab/src/net/mod.rs b/crates/flowstate-collab/src/net/mod.rs index 93ed6562..853ab0cb 100644 --- a/crates/flowstate-collab/src/net/mod.rs +++ b/crates/flowstate-collab/src/net/mod.rs @@ -56,6 +56,7 @@ pub enum NetCommand { handler: DirectSessionHandler, }, ConfigureStandingAccess { + document: crate::ticket::DocumentKind, session: SessionId, document_fingerprint: [u8; 32], title: String, diff --git a/crates/flowstate-collab/src/net/runtime.rs b/crates/flowstate-collab/src/net/runtime.rs index 95bdd7c8..ec1b373f 100644 --- a/crates/flowstate-collab/src/net/runtime.rs +++ b/crates/flowstate-collab/src/net/runtime.rs @@ -144,13 +144,14 @@ async fn net_main(cmd_rx: async_channel::Receiver, evt_tx: async_cha direct_state.register_handler(session, handler).await; }, NetCommand::ConfigureStandingAccess { + document, session, document_fingerprint, title, identities, } => { direct_state - .configure_standing_access(session, document_fingerprint, title, identities.into_iter().collect()) + .configure_standing_access(session, document_fingerprint, title, document, identities.into_iter().collect()) .await; }, NetCommand::RequestDiscoveredTicket { @@ -180,6 +181,7 @@ async fn net_main(cmd_rx: async_channel::Receiver, evt_tx: async_cha vec![advertisement.endpoint], grant.title, grant.admission, + grant.document, )) } .await; diff --git a/crates/flowstate-collab/src/presence.rs b/crates/flowstate-collab/src/presence.rs index eeea19de..0ae20401 100644 --- a/crates/flowstate-collab/src/presence.rs +++ b/crates/flowstate-collab/src/presence.rs @@ -24,7 +24,48 @@ pub const MAX_PRESENCE_CURSOR_BYTES: usize = 256; pub struct PresenceState { pub name: String, pub color_rgb: u32, - pub selection: Option, + pub focus: Option, +} + +/// What the peer is focused on — kind-specific (spec Part C, presence). The +/// wire change from `Option` is covered by the protocol v4 +/// bump: v3 peers never reach presence exchange with a v4 session. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum PresenceFocus { + /// Rich-text (.db8) selection in the document body. + Text(PresenceSelection), + /// Flow (.fl0) board focus. + Flow(FlowPresenceFocus), +} + +impl PresenceFocus { + #[must_use] + pub fn text(&self) -> Option<&PresenceSelection> { + match self { + Self::Text(selection) => Some(selection), + Self::Flow(_) => None, + } + } + + #[must_use] + pub fn flow(&self) -> Option<&FlowPresenceFocus> { + match self { + Self::Flow(focus) => Some(focus), + Self::Text(_) => None, + } + } +} + +/// A peer's board focus inside a flow session: which sheet they view, which +/// cell they focus, and — because cells are real CRDT text — an exact caret as +/// encoded Loro cursors (resolved under the flow gate on the receiving side). +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct FlowPresenceFocus { + pub sheet: Option, + pub cell: Option, + /// Whether the peer has the cell's editor open (vs merely selecting it). + pub editing: bool, + pub caret: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -93,7 +134,7 @@ pub struct RosterEntry { pub key: String, pub name: String, pub color_rgb: u32, - pub selection: Option, + pub focus: Option, } #[derive(Clone, Debug)] @@ -282,22 +323,30 @@ fn roster_entry_from_value(key: String, value: LoroValue) -> Option color_rgb: state.color_rgb & 0x00ff_ffff, key, name: state.name, - selection: state.selection, + focus: state.focus, }) } /// Clamp a presence state to the Part B caps before it is broadcast: strip -/// control characters, truncate the display name, and drop a selection whose +/// control characters, truncate the display name, and drop a focus whose /// cursor encodings exceed the cap (a corrupt/oversized cursor is unusable). fn sanitize_presence_state(state: &PresenceState) -> PresenceState { - let selection = match &state.selection { - Some(selection) if selection_within_caps(selection) => Some(selection.clone()), + let focus = match &state.focus { + Some(focus) if focus_within_caps(focus) => Some(focus.clone()), + Some(PresenceFocus::Flow(focus)) => { + // A flow focus with only an oversized caret is still useful board + // presence — drop just the caret rather than the whole focus. + Some(PresenceFocus::Flow(FlowPresenceFocus { + caret: None, + ..focus.clone() + })) + }, _ => None, }; PresenceState { name: sanitize_display_name(&state.name), color_rgb: state.color_rgb & 0x00ff_ffff, - selection, + focus, } } @@ -323,7 +372,14 @@ fn truncate_to_bytes(text: &str, max_bytes: usize) -> String { fn presence_state_within_caps(state: &PresenceState) -> bool { state.name.len() <= MAX_PRESENCE_NAME_BYTES && !state.name.chars().any(|c| c.is_control()) - && state.selection.as_ref().is_none_or(selection_within_caps) + && state.focus.as_ref().is_none_or(focus_within_caps) +} + +fn focus_within_caps(focus: &PresenceFocus) -> bool { + match focus { + PresenceFocus::Text(selection) => selection_within_caps(selection), + PresenceFocus::Flow(focus) => focus.caret.as_ref().is_none_or(selection_within_caps), + } } fn selection_within_caps(selection: &PresenceSelection) -> bool { @@ -344,7 +400,7 @@ mod tests { PresenceState { name: name.to_string(), color_rgb: 0x3b82f6, - selection: None, + focus: None, } } diff --git a/crates/flowstate-collab/src/proto_direct.rs b/crates/flowstate-collab/src/proto_direct.rs index 7e2ee81a..65ce3cde 100644 --- a/crates/flowstate-collab/src/proto_direct.rs +++ b/crates/flowstate-collab/src/proto_direct.rs @@ -51,6 +51,9 @@ pub enum DirectRequest { pub struct DiscoveryAdmissionGrant { pub admission: SessionAdmission, pub title: String, + /// The session's document kind (v4) — carried into the minted ticket. + #[serde(default)] + pub document: crate::ticket::DocumentKind, } /// How a streamed direct payload is encoded on the wire. Rides on the response diff --git a/crates/flowstate-collab/src/proto_gossip.rs b/crates/flowstate-collab/src/proto_gossip.rs index 98ebfee8..590f5bad 100644 --- a/crates/flowstate-collab/src/proto_gossip.rs +++ b/crates/flowstate-collab/src/proto_gossip.rs @@ -5,9 +5,12 @@ use serde::{Deserialize, Serialize}; use crate::ids::{BlobId, SessionId}; -/// Version 3 uses symmetric, session-lifetime editor admission. Pre-release -/// protocol versions are intentionally not accepted. -pub const PROTOCOL_VERSION: u16 = 3; +/// Version 4 adds the session DOCUMENT KIND (rich text vs flow) to the +/// invite ticket — HMAC-covered, so a ticket's kind cannot be flipped — and +/// flow-aware presence focus. Version 3 used symmetric, session-lifetime +/// editor admission; pre-release protocol versions are intentionally not +/// accepted. +pub const PROTOCOL_VERSION: u16 = 4; pub const GOSSIP_INLINE_LIMIT: usize = 2 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/flowstate-collab/src/sync_io.rs b/crates/flowstate-collab/src/sync_io.rs new file mode 100644 index 00000000..7d302382 --- /dev/null +++ b/crates/flowstate-collab/src/sync_io.rs @@ -0,0 +1,67 @@ +//! `SyncIoHandle` — the kind-agnostic face of a session's document I/O +//! service (flow spec Part C). Transport code (direct pulls, anti-entropy) +//! needs exactly three calls — oplog version vector, incremental update +//! export, snapshot export — and bytes are bytes: the two runtimes differ +//! only in which gate the I/O thread answers under. + +use anyhow::Result; + +use crate::doc_io::DocIoHandle; +use crate::flow::FlowIoHandle; + +#[derive(Clone)] +pub enum SyncIoHandle { + RichText(DocIoHandle), + Flow(FlowIoHandle), +} + +impl SyncIoHandle { + pub async fn oplog_version_vector(&self) -> Result> { + match self { + Self::RichText(io) => io.oplog_version_vector().await, + Self::Flow(io) => io.oplog_version_vector().await, + } + } + + pub async fn export_updates_for(&self, remote_vv: Vec) -> Result> { + match self { + Self::RichText(io) => io.export_updates_for(remote_vv).await, + Self::Flow(io) => io.export_updates_for(remote_vv).await, + } + } + + pub async fn snapshot_bytes(&self) -> Result> { + match self { + Self::RichText(io) => io.snapshot_bytes().await, + Self::Flow(io) => io.snapshot_bytes().await, + } + } + + #[must_use] + pub fn as_rich_text(&self) -> Option<&DocIoHandle> { + match self { + Self::RichText(io) => Some(io), + Self::Flow(_) => None, + } + } + + #[must_use] + pub fn as_flow(&self) -> Option<&FlowIoHandle> { + match self { + Self::Flow(io) => Some(io), + Self::RichText(_) => None, + } + } +} + +impl From for SyncIoHandle { + fn from(io: DocIoHandle) -> Self { + Self::RichText(io) + } +} + +impl From for SyncIoHandle { + fn from(io: FlowIoHandle) -> Self { + Self::Flow(io) + } +} diff --git a/crates/flowstate-collab/src/ticket.rs b/crates/flowstate-collab/src/ticket.rs index ebd874a4..d145137b 100644 --- a/crates/flowstate-collab/src/ticket.rs +++ b/crates/flowstate-collab/src/ticket.rs @@ -8,7 +8,18 @@ use crate::{SessionId, admission::SessionAdmission, proto_gossip::PROTOCOL_VERSI pub const TICKET_KIND: &str = "fscollab"; pub const INVITE_URL_PREFIX: &str = "flowstate://join#"; -/// Symmetric editor invite for one ephemeral live session (wire version 3). +/// What kind of document a live session shares. Rides the invite ticket +/// UNDER the HMAC tag (wire v4), so a ticket's kind cannot be flipped in +/// transit — a joiner spins up the right runtime before the first byte of +/// snapshot arrives (snapshot schema sniffing stays as defense-in-depth). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum DocumentKind { + #[default] + RichText, + Flow, +} + +/// Symmetric editor invite for one ephemeral live session (wire version 4). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SessionTicket { pub version: u16, @@ -16,18 +27,20 @@ pub struct SessionTicket { pub bootstrap: Vec, pub title: String, pub admission: SessionAdmission, + pub document: DocumentKind, tag: [u8; 32], } impl SessionTicket { #[must_use] - pub fn new(session: SessionId, bootstrap: Vec, title: String, admission: SessionAdmission) -> Self { + pub fn new(session: SessionId, bootstrap: Vec, title: String, admission: SessionAdmission, document: DocumentKind) -> Self { let mut ticket = Self { version: PROTOCOL_VERSION, session, bootstrap, title, admission, + document, tag: [0; 32], }; ticket.tag = ticket @@ -41,17 +54,31 @@ impl SessionTicket { self.version == PROTOCOL_VERSION } + /// Version-mismatch UX: a specific, actionable message per direction + /// instead of a generic failure. + fn version_mismatch_message(version: u16) -> Option<&'static str> { + match version.cmp(&PROTOCOL_VERSION) { + std::cmp::Ordering::Less => Some("this collaboration invite is outdated and can no longer be joined — ask for a fresh invite"), + std::cmp::Ordering::Greater => Some("this collaboration invite was created by a newer Flowstate — update Flowstate to join"), + std::cmp::Ordering::Equal => None, + } + } + /// Authenticate all user-visible and routing metadata before any dial. pub fn verify_for_join(&self) -> Result<()> { - ensure!(self.is_supported_version(), "unsupported collaboration protocol version"); + if let Some(message) = Self::version_mismatch_message(self.version) { + anyhow::bail!(message); + } ensure!(!self.bootstrap.is_empty(), "collaboration invite has no reachable participants"); ensure!(self.tag == self.expected_tag()?, "collaboration invite metadata is invalid"); Ok(()) } fn expected_tag(&self) -> Result<[u8; 32]> { - let payload = - postcard::to_stdvec(&(self.version, self.session, &self.bootstrap, &self.title)).context("encoding collaboration invite metadata")?; + // The document KIND is tag-covered (v4): flipping it would route a joiner + // into the wrong runtime kind. + let payload = postcard::to_stdvec(&(self.version, self.session, &self.bootstrap, &self.title, self.document)) + .context("encoding collaboration invite metadata")?; Ok(self.admission.tag(&payload)) } @@ -82,6 +109,14 @@ impl Ticket for SessionTicket { } fn decode_bytes(bytes: &[u8]) -> Result { + // Leading-varint pre-decode: the version is the first field, so a ticket + // from a DIFFERENT wire version (whose full struct layout won't parse) + // still yields the right UX message instead of a generic decode failure. + if let Ok((version, _)) = postcard::take_from_bytes::(bytes) + && let Some(message) = Self::version_mismatch_message(version) + { + return Err(ParseError::verification_failed(message)); + } let ticket: Self = postcard::from_bytes(bytes).map_err(|_| ParseError::verification_failed("invalid collaboration ticket payload"))?; ticket .verify_for_join() @@ -103,6 +138,7 @@ mod tests { vec![EndpointAddr::new(SecretKey::from_bytes(&[2; 32]).public())], "Shared brief".to_string(), SessionAdmission::from_bytes([3; 32]), + super::DocumentKind::Flow, ) } @@ -139,6 +175,32 @@ mod tests { assert!(ticket.verify_for_join().is_err()); } + #[test] + fn document_kind_round_trips_and_is_tag_covered() { + let encoded = ticket().encode_text(); + let decoded = SessionTicket::decode_text(&encoded).expect("decode"); + assert_eq!(decoded.document, super::DocumentKind::Flow); + // Kind-flipping is an HMAC failure, not a silent reroute. + let mut flipped = ticket(); + flipped.document = super::DocumentKind::RichText; + assert!(flipped.verify_for_join().is_err()); + } + + #[test] + fn version_mismatch_yields_actionable_messages() { + use iroh_tickets::Ticket as _; + let mut old = ticket(); + old.version = 3; + let bytes = old.encode_bytes(); + let error = SessionTicket::decode_bytes(&bytes).expect_err("v3 must be rejected"); + assert!(error.to_string().contains("outdated"), "{error}"); + let mut newer = ticket(); + newer.version = 99; + let bytes = newer.encode_bytes(); + let error = SessionTicket::decode_bytes(&bytes).expect_err("future version must be rejected"); + assert!(error.to_string().contains("newer Flowstate"), "{error}"); + } + #[test] fn any_participant_endpoint_can_be_the_bootstrap() { let mut ticket = ticket(); diff --git a/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs b/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs new file mode 100644 index 00000000..9635f423 --- /dev/null +++ b/crates/flowstate-collab/tests/flow_cell_text_fuzz.rs @@ -0,0 +1,374 @@ +//! Same-cell concurrent rich-text fuzz (build-order step 5) — the two headline +//! defects asserted dead: +//! 1. concurrent char-level edits INSIDE one cell interleave (no +//! last-writer-wins blob clobber); +//! 2. a reorder can NEVER lose a concurrent keystroke (order-list writes never +//! touch text containers). +//! +//! Peers drive the REAL write path: `FlowCellAuthority` → `FlowIntent::CellText` +//! → gated commit, exactly what an attached `RichTextEditor` does. + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use flowstate_collab::flow::{FlowCellAuthority, FlowDocHandle, FlowRuntime}; + use flowstate_collab::local_write::WriteGate; + use flowstate_flow::format::{CellId, FlowFormat, SheetId}; + use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent}; + use gpui_flowtext::local_intents::{ + DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, LocalWriteAuthority as _, SetMarksIntent, SplitParagraphIntent, + TextAnchor, WriteRejected, + }; + use gpui_flowtext::{ParagraphStyle, RunStyles}; + use uuid::Uuid; + + struct Rng(u64); + + impl Rng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound.max(1) as u64) as usize + } + } + + struct Peer { + handle: Arc, + authority: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, + } + + impl Peer { + fn oplog_vv(&self) -> loro::VersionVector { + self + .handle + .with_test_runtime(|runtime| runtime.oplog_version_vector()) + } + + fn cell_text(&self, cell: CellId) -> String { + self.handle.with_test_runtime(|runtime| { + let rows = flowstate_flow::loro_projection::materialize_cell_rows(runtime.doc(), cell).expect("cell rows"); + rows + .blocks + .iter() + .filter_map(|block| match block { + gpui_flowtext::InputBlock::Paragraph(paragraph) => Some( + paragraph + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + ), + _ => None, + }) + .collect::>() + .join("\n") + }) + } + } + + fn sync_all(peers: &[Peer]) { + for _ in 0..2 { + for a in 0..peers.len() { + for b in 0..peers.len() { + if a == b { + continue; + } + let vv = peers[b].oplog_vv(); + if let Ok(bytes) = peers[a] + .handle + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)) + && !bytes.is_empty() + { + peers[b] + .handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .expect("import"); + } + } + } + } + } + + fn spawn(peers_n: usize) -> (Vec, SheetId, CellId) { + let format = FlowFormat::policy_debate(); + let (seed_handle, _seed_gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("seed runtime")); + let sheet = Uuid::new_v4(); + let cell = Uuid::new_v4(); + seed_handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Text fuzz".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("sheet"); + for column_index in 0..2 { + // A second cell so reorders have something to move against. + seed_handle + .apply(FlowIntent::AddCell { + sheet_id: sheet, + cell_id: if column_index == 0 { cell } else { Uuid::new_v4() }, + placement: CellPlacement::ColumnEnd { column_index: 0 }, + seed: CellSeed::Empty, + }) + .expect("cell"); + } + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); + let peers = (0..peers_n) + .map(|_| { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer")); + let authority = handle.cell_authority(cell); + let _ = handle.open_cell(cell).expect("open cell"); + Peer { handle, authority, gate } + }) + .collect(); + (peers, sheet, cell) + } + + /// One random cell-text intent through the peer's authority. Expected + /// rejections (anchors moved by concurrent edits) are fine. + fn random_text_intent(rng: &mut Rng, peer: &Peer, step: usize) -> Result<(), WriteRejected> { + let projection = peer.authority.canonical_projection()?; + let paragraph_count = projection.paragraphs.len(); + let pick_anchor = |rng: &mut Rng| { + let ix = rng.below(paragraph_count); + let id = projection.ids.paragraph_ids[ix]; + let len = flowstate_document::paragraph_text_len(&projection.paragraphs[ix]); + TextAnchor::new(id, rng.below(len + 1)) + }; + let intent = match rng.below(6) { + 0 | 1 => LocalIntent::InsertText(InsertTextIntent { + at: pick_anchor(rng), + text: format!("w{step} "), + style_override: None, + }), + 2 => LocalIntent::DeleteRange(DeleteRangeIntent { + start: pick_anchor(rng), + end: pick_anchor(rng), + }), + 3 => LocalIntent::SplitParagraph(SplitParagraphIntent { + at: pick_anchor(rng), + inherited_style: ParagraphStyle::Normal, + }), + 4 => { + if paragraph_count < 2 { + return Ok(()); + } + let first_ix = rng.below(paragraph_count - 1); + LocalIntent::JoinParagraphs(JoinParagraphsIntent { + first: projection.ids.paragraph_ids[first_ix], + second: projection.ids.paragraph_ids[first_ix + 1], + }) + }, + _ => LocalIntent::SetMarks(SetMarksIntent { + start: pick_anchor(rng), + end: pick_anchor(rng), + styles: RunStyles { + strikethrough: rng.below(2) == 0, + direct_underline: rng.below(2) == 0, + ..RunStyles::default() + }, + }), + }; + peer.authority.apply(intent).map(|_| ()) + } + + fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { + let mut rng = Rng::new(seed); + let (peers, sheet, cell) = spawn(peers_n); + + for round in 0..rounds { + for op in 0..ops_per_round { + let peer_ix = rng.below(peers_n); + let step = round * ops_per_round + op; + // Interleave the occasional structural reorder with the typing storm — + // the reorder-vs-edit headline arm. + if rng.below(8) == 0 { + let board = peers[peer_ix].handle.board_projection().expect("board"); + let cells: Vec = board + .sheet(sheet) + .expect("sheet") + .cells + .iter() + .map(|cell| cell.id) + .collect(); + if cells.len() > 1 { + let target = cells[rng.below(cells.len())]; + if target != cell { + let _ = peers[peer_ix].handle.apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: cell, + drop: if rng.below(2) == 0 { + FlowDropIntent::BeforeSibling(target) + } else { + FlowDropIntent::AfterSibling(target) + }, + }); + } + } + } + match random_text_intent(&mut rng, &peers[peer_ix], step) { + Ok(()) | Err(WriteRejected::EmptyIntent | WriteRejected::StructureViolation(_) | WriteRejected::UnresolvedParagraph(_)) => {}, + Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), + } + } + sync_all(&peers); + + let reference_vv = peers[0].oplog_vv(); + let reference_text = peers[0].cell_text(cell); + let reference_board = peers[0].handle.board_projection().expect("board"); + for (ix, peer) in peers.iter().enumerate().skip(1) { + assert_eq!( + peer.oplog_vv(), + reference_vv, + "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" + ); + assert_eq!( + peer.cell_text(cell), + reference_text, + "seed {seed} round {round}: peer {ix} cell text diverged" + ); + assert_eq!( + peer.handle.board_projection().expect("board"), + reference_board, + "seed {seed} round {round}: peer {ix} board diverged" + ); + } + } + } + + /// THE headline pair, deterministic form: peer A types while peer B reorders + /// the same cell; both edits survive the merge exactly. + #[test] + fn reorder_never_loses_a_concurrent_keystroke() { + let (peers, sheet, cell) = spawn(2); + let projection = peers[0] + .authority + .canonical_projection() + .expect("projection"); + let paragraph = projection.ids.paragraph_ids[0]; + + peers[0] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "surviving keystrokes".into(), + style_override: None, + })) + .expect("typing"); + let board = peers[1].handle.board_projection().expect("board"); + let other = board + .sheet(sheet) + .expect("sheet") + .cells + .iter() + .map(|candidate| candidate.id) + .find(|id| *id != cell) + .expect("other cell"); + peers[1] + .handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: cell, + drop: FlowDropIntent::AfterSibling(other), + }) + .expect("reorder"); + + sync_all(&peers); + for peer in &peers { + assert_eq!(peer.cell_text(cell), "surviving keystrokes"); + let board = peer.handle.board_projection().expect("board"); + let order: Vec = board + .sheet(sheet) + .expect("sheet") + .cells + .iter() + .map(|cell| cell.id) + .collect(); + assert_eq!(order, vec![other, cell], "the reorder survived alongside the keystrokes"); + } + } + + #[test] + fn concurrent_same_cell_edits_interleave() { + let (peers, _sheet, cell) = spawn(2); + let projection = peers[0] + .authority + .canonical_projection() + .expect("projection"); + let paragraph = projection.ids.paragraph_ids[0]; + peers[0] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "left".into(), + style_override: None, + })) + .expect("peer 0 types"); + peers[1] + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "right".into(), + style_override: None, + })) + .expect("peer 1 types"); + sync_all(&peers); + let merged = peers[0].cell_text(cell); + assert_eq!(merged, peers[1].cell_text(cell)); + assert!( + merged.contains("left") && merged.contains("right"), + "both peers' characters survive char-level merge: {merged:?}" + ); + } + + #[test] + fn two_peer_cell_text_fuzz_converges() { + for seed in [1, 7, 42, 505, 1337, 8738] { + run_fuzz(seed, 2, 6, 12); + } + } + + #[test] + fn three_peer_cell_text_fuzz_converges() { + for seed in [3, 99, 4242, 20260707] { + run_fuzz(seed, 3, 5, 10); + } + } + + /// Long-horizon soak (10k+ text intents across the sweep), ignored by default. + #[test] + #[ignore = "long-horizon soak — run via `heaven.sh soak`"] + fn flow_cell_text_soak() { + let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8); + let ops: usize = std::env::var("FUZZ_SOAK_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(16); + for seed in 1..=seeds { + let peers = 2 + (seed % 2) as usize; + run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); + if seed % 25 == 0 { + eprintln!("[soak] flow_cell_text {seed}/{seeds}"); + } + } + } +} diff --git a/crates/flowstate-collab/tests/flow_convergence.rs b/crates/flowstate-collab/tests/flow_convergence.rs new file mode 100644 index 00000000..3861bea9 --- /dev/null +++ b/crates/flowstate-collab/tests/flow_convergence.rs @@ -0,0 +1,306 @@ +//! Flow board convergence fuzz (build-order step 5): N peers apply random +//! STRUCTURAL intents through their gated flow handles, sync in rounds, and +//! must converge — every peer's maintained board equal to every other's AND +//! equal to a fresh materialization of its own canonical state (the one +//! derivation law). Mirrors the .db8 `intent_fuzz` harness shape: fixed CI +//! seeds + an ignored long-horizon soak. + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use flowstate_collab::flow::{FlowDocHandle, FlowRuntime, FlowWriteRejected}; + use flowstate_collab::local_write::WriteGate; + use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; + use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; + use flowstate_flow::loro_projection::materialize_board; + use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; + use uuid::Uuid; + + struct Rng(u64); + + impl Rng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() % bound.max(1) as u64) as usize + } + } + + struct Peer { + handle: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, + } + + impl Peer { + fn board(&self) -> flowstate_flow::projection::FlowBoardProjection { + self.handle.board_projection().expect("board") + } + + fn oplog_vv(&self) -> loro::VersionVector { + self + .handle + .with_test_runtime(|runtime| runtime.oplog_version_vector()) + } + } + + fn spawn_peers(count: usize, format: &FlowFormat, base_sheet: SheetId) -> Vec { + let seed_runtime = FlowRuntime::new(format).expect("seed runtime"); + let (seed_handle, _seed_gate) = FlowDocHandle::new(seed_runtime); + seed_handle + .apply(FlowIntent::CreateSheet { + sheet_id: base_sheet, + name: "Fuzz".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("seed sheet"); + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot().expect("seed snapshot")); + (0..count) + .map(|_| { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer runtime")); + Peer { handle, gate } + }) + .collect() + } + + fn sync_all(peers: &[Peer]) { + // Full mesh, two passes → quiescent for any peer count used here. + for _ in 0..2 { + for a in 0..peers.len() { + for b in 0..peers.len() { + if a == b { + continue; + } + let vv = peers[b].oplog_vv(); + let bytes = peers[a] + .handle + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)); + if let Ok(bytes) = bytes + && !bytes.is_empty() + { + peers[b] + .handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .expect("import"); + } + } + } + } + } + + fn stroke(sheet: SheetId, originator: &str, rng: &mut Rng) -> AnnotationStroke { + AnnotationStroke { + id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + sheet_id: sheet, + originator: AnnotationOriginator(originator.into()), + points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], + style: StrokeStyle { + color_rgba: 0xff00_00ff, + width: 1.0, + opacity: 1.0, + }, + bbox: BoardRect::default(), + } + } + + /// One random structural intent against `peer`. Rejections from racing + /// deletions are expected; anything else is a bug. + fn random_intent(rng: &mut Rng, peer: &Peer, sheet: SheetId, step: usize) -> Result<(), FlowWriteRejected> { + let board = peer.board(); + let cells: Vec = board + .sheet(sheet) + .map(|sheet| sheet.cells.iter().map(|cell| cell.id).collect()) + .unwrap_or_default(); + let pick = |rng: &mut Rng, cells: &[CellId]| cells.get(rng.below(cells.len())).copied(); + let intent = match rng.below(10) { + 0 => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: CellPlacement::ColumnEnd { column_index: rng.below(3) }, + seed: CellSeed::Empty, + }, + 1 => match pick(rng, &cells) { + Some(parent) => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: if rng.below(2) == 0 { + CellPlacement::ResponseTo { parent } + } else { + CellPlacement::FirstResponseTo { parent } + }, + seed: CellSeed::Empty, + }, + None => return Ok(()), + }, + 2 => match pick(rng, &cells) { + Some(of) => FlowIntent::AddCell { + sheet_id: sheet, + cell_id: Uuid::from_u128(rng.next() as u128 | ((rng.next() as u128) << 64)), + placement: CellPlacement::Sibling { + of, + position: if rng.below(2) == 0 { + RelativePosition::Before + } else { + RelativePosition::After + }, + }, + seed: CellSeed::Empty, + }, + None => return Ok(()), + }, + 3 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::DeleteCell { sheet_id: sheet, cell_id }, + None => return Ok(()), + }, + 4 => match (pick(rng, &cells), pick(rng, &cells)) { + (Some(cell_id), Some(target)) if cell_id != target => FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id, + drop: match rng.below(5) { + 0 => FlowDropIntent::BeforeSibling(target), + 1 => FlowDropIntent::AfterSibling(target), + 2 => FlowDropIntent::FirstChildOf(target), + 3 => FlowDropIntent::LastChildOf(target), + _ => FlowDropIntent::RootInColumn { + column_index: rng.below(3), + insertion_index: rng.below(cells.len() + 1), + }, + }, + }, + _ => return Ok(()), + }, + 5 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::SetCellStruck { + sheet_id: sheet, + cell_id, + struck: rng.below(2) == 0, + }, + None => return Ok(()), + }, + 6 => match pick(rng, &cells) { + Some(cell_id) => FlowIntent::ReplaceCellContent { + sheet_id: sheet, + cell_id, + paragraphs: vec![InputParagraph { + style: ParagraphStyle::Custom(3), + runs: vec![InputRun { + text: format!("edit {step}"), + styles: RunStyles::default(), + }], + }], + }, + None => return Ok(()), + }, + 7 => FlowIntent::AddAnnotation { + sheet_id: sheet, + stroke: stroke(sheet, "fuzz", rng), + }, + 8 => FlowIntent::ClearAnnotations { + sheet_id: Some(sheet), + originator: AnnotationOriginator("fuzz".into()), + }, + _ => FlowIntent::RenameSheet { + sheet_id: sheet, + name: format!("Fuzz {step}"), + }, + }; + peer.handle.apply(intent).map(|_| ()) + } + + fn run_fuzz(seed: u64, peers_n: usize, rounds: usize, ops_per_round: usize) { + let mut rng = Rng::new(seed); + let format = FlowFormat::policy_debate(); + let sheet = Uuid::new_v4(); + let peers = spawn_peers(peers_n, &format, sheet); + + for round in 0..rounds { + for op in 0..ops_per_round { + let peer_ix = rng.below(peers_n); + let step = round * ops_per_round + op; + match random_intent(&mut rng, &peers[peer_ix], sheet, step) { + Ok(()) + | Err(FlowWriteRejected::EmptyIntent | FlowWriteRejected::StructureViolation(_)) + | Err(FlowWriteRejected::UnknownSheet(_) | FlowWriteRejected::UnknownCell(_)) => {}, + Err(other) => panic!("seed {seed} round {round} op {op}: unexpected rejection {other}"), + } + } + sync_all(&peers); + + // Self-consistency: maintained board == fresh materialization. + for (ix, peer) in peers.iter().enumerate() { + let maintained = peer.board(); + let fresh = peer.handle.with_test_runtime(|runtime| { + materialize_board(runtime.doc()) + .expect("fresh materialize") + .0 + }); + assert_eq!( + maintained, fresh, + "seed {seed} round {round}: peer {ix} maintained board deviates from its own canonical state" + ); + } + // Cross-peer convergence. + let reference = peers[0].board(); + let reference_vv = peers[0].oplog_vv(); + for (ix, peer) in peers.iter().enumerate().skip(1) { + assert_eq!( + peer.oplog_vv(), + reference_vv, + "seed {seed} round {round}: peer {ix} version vector differs after quiescent sync" + ); + assert_eq!(peer.board(), reference, "seed {seed} round {round}: peer {ix} board diverged"); + } + } + } + + #[test] + fn two_peer_flow_fuzz_converges() { + for seed in [1, 7, 42, 505, 1337, 8738] { + run_fuzz(seed, 2, 6, 12); + } + } + + #[test] + fn three_peer_flow_fuzz_converges() { + for seed in [3, 99, 4242, 20260707] { + run_fuzz(seed, 3, 5, 10); + } + } + + /// Long-horizon randomized soak (10k+ intents across the sweep), ignored by + /// default like the .db8 soaks — run via `heaven.sh soak`; tune with + /// `FUZZ_SOAK_SEEDS`/`FUZZ_SOAK_ROUNDS`/`FUZZ_SOAK_OPS`. + #[test] + #[ignore = "long-horizon soak — run via `heaven.sh soak`"] + fn flow_fuzz_soak() { + let seeds: u64 = std::env::var("FUZZ_SOAK_SEEDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500); + let rounds: usize = std::env::var("FUZZ_SOAK_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8); + let ops: usize = std::env::var("FUZZ_SOAK_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(16); + for seed in 1..=seeds { + let peers = 2 + (seed % 3) as usize; + run_fuzz(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15), peers, rounds, ops); + if seed % 25 == 0 { + eprintln!("[soak] flow_convergence {seed}/{seeds}"); + } + } + } +} diff --git a/crates/flowstate-collab/tests/flow_io_pump.rs b/crates/flowstate-collab/tests/flow_io_pump.rs new file mode 100644 index 00000000..3a2d3737 --- /dev/null +++ b/crates/flowstate-collab/tests/flow_io_pump.rs @@ -0,0 +1,229 @@ +//! The flow I/O pump harness — the `doc_io_pump` drip suite mirrored onto +//! `FlowIoHandle` (build-order step 6): §6.4 import coalescing behind a held +//! gate, pathological delivery order, deferred non-import ordering, the +//! exactly-once publish pump, and the `.fl0` save/recovery byte paths. + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use anyhow::Result; + use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowPublishEvent, FlowRuntime}; + use flowstate_collab::local_write::{GateHolder, WriteGate}; + use flowstate_flow::format::{FlowFormat, SheetId}; + use flowstate_flow::intents::FlowIntent; + use uuid::Uuid; + + type FlowGate = Arc>; + + /// A based (source handle, receiver gate) pair plus `edits` sheet renames + /// exported as one delta blob PER EDIT (the network drip shape) and the + /// source's final board. + fn drip_pair(edits: usize) -> Result<(flowstate_flow::projection::FlowBoardProjection, Vec>, FlowGate)> { + let format = FlowFormat::policy_debate(); + let (source_handle, source_gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let sheet: SheetId = Uuid::new_v4(); + source_handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Drip".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .map_err(|error| anyhow::anyhow!("seed sheet rejected: {error}"))?; + let snapshot = source_handle.with_test_runtime(|runtime| runtime.snapshot())?; + let (_receiver_handle, receiver_gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot)?); + + let mut blobs = Vec::with_capacity(edits); + for edit_ix in 0..edits { + let vv_before = source_gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .oplog_version_vector(); + source_handle + .apply(FlowIntent::RenameSheet { + sheet_id: sheet, + name: format!("Drip {edit_ix}"), + }) + .map_err(|error| anyhow::anyhow!("drip edit {edit_ix} rejected: {error}"))?; + let blob = source_gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .export_updates_for(&vv_before)?; + blobs.push(blob); + } + let final_board = source_handle + .board_projection() + .map_err(|error| anyhow::anyhow!("board: {error}"))?; + Ok((final_board, blobs, receiver_gate)) + } + + fn board(gate: &FlowGate) -> flowstate_flow::projection::FlowBoardProjection { + gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .board_ref() + .clone() + } + + fn import_batches(gate: &FlowGate) -> u64 { + gate + .lock(GateHolder::ExportUpdates) + .expect("gate healthy") + .import_batches_served() + } + + /// A 40-blob drip fired while the gate is HELD must coalesce into a few + /// batched imports, every reply must arrive, and the receiver must converge. + #[test] + fn held_gate_drip_coalesces_into_bounded_batches() -> Result<()> { + const EDITS: usize = 40; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let batches_before = import_batches(&gate); + let hold = gate + .lock(GateHolder::DocumentService) + .expect("gate healthy"); + #[allow(clippy::needless_collect, reason = "spawn-all-then-join-all; lazy iteration would serialize the burst")] + let senders: Vec<_> = blobs + .iter() + .map(|blob| { + let io_import = io.clone(); + let blob = blob.clone(); + std::thread::spawn(move || pollster::block_on(io_import.import_remote_update(blob))) + }) + .collect(); + std::thread::sleep(std::time::Duration::from_millis(200)); + drop(hold); + let replies: Vec<_> = senders + .into_iter() + .map(|sender| sender.join().expect("sender join")) + .collect(); + + assert_eq!(replies.len(), EDITS); + for (blob_ix, reply) in replies.iter().enumerate() { + assert!(reply.is_ok(), "blob {blob_ix} reply failed: {reply:?}"); + } + assert_eq!(board(&gate), source_board, "receiver did not converge to the source board"); + let batches = import_batches(&gate) - batches_before; + assert!( + (3..=8).contains(&batches), + "expected 3..=8 coalesced batches for {EDITS} blobs behind a held gate, saw {batches}" + ); + Ok(()) + } + + /// Duplicated + reversed blobs: Loro buffers out-of-order updates and + /// re-applies duplicates as no-ops — every reply Ok, receiver converges. + #[test] + fn duplicated_and_reversed_drip_converges() -> Result<()> { + const EDITS: usize = 12; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let mut schedule: Vec> = Vec::new(); + for blob in blobs.iter().rev() { + schedule.push(blob.clone()); + schedule.push(blob.clone()); + } + let replies = pollster::block_on(async { + let mut replies = Vec::new(); + for blob in schedule { + replies.push(io.import_remote_update(blob).await); + } + replies + }); + for (delivery_ix, reply) in replies.iter().enumerate() { + assert!(reply.is_ok(), "delivery {delivery_ix} reply failed: {reply:?}"); + } + assert_eq!(board(&gate), source_board, "receiver did not converge under duplicated reversed delivery"); + Ok(()) + } + + /// A non-import request interleaved mid-burst is DEFERRED, not dropped. + #[test] + fn non_import_request_interleaved_with_burst_still_replies() -> Result<()> { + const EDITS: usize = 10; + let (source_board, blobs, gate) = drip_pair(EDITS)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + + let hold = gate + .lock(GateHolder::DocumentService) + .expect("gate healthy"); + let io_imports = io.clone(); + let import_thread = std::thread::spawn(move || { + pollster::block_on(async move { + let mut replies = Vec::new(); + for blob in blobs { + replies.push(io_imports.import_remote_update(blob).await); + } + replies + }) + }); + let io_snapshot = io.clone(); + let snapshot_thread = std::thread::spawn(move || pollster::block_on(io_snapshot.board_snapshot())); + std::thread::sleep(std::time::Duration::from_millis(100)); + drop(hold); + + let replies = import_thread.join().expect("imports join"); + for reply in &replies { + assert!(reply.is_ok(), "import reply failed: {reply:?}"); + } + let snapshot = snapshot_thread.join().expect("snapshot join"); + assert!(snapshot.is_ok(), "deferred non-import request lost/errored: {snapshot:?}"); + assert_eq!(board(&gate), source_board); + Ok(()) + } + + /// The publish pump drains committed local events exactly once. + #[test] + fn pump_publish_drains_exactly_once() -> Result<()> { + let format = FlowFormat::policy_debate(); + let (handle, gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + let _ = pollster::block_on(io.pump_publish())?; + handle + .apply(FlowIntent::CreateSheet { + sheet_id: Uuid::new_v4(), + name: "Published".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .map_err(|error| anyhow::anyhow!("edit rejected: {error}"))?; + let first = pollster::block_on(io.pump_publish())?; + assert!( + first + .iter() + .any(|event| matches!(event, FlowPublishEvent::LocalUpdate { .. })), + "first pump must return the committed LocalUpdate events" + ); + let second = pollster::block_on(io.pump_publish())?; + assert!(second.is_empty(), "second pump must be empty — double-publish hazard"); + Ok(()) + } + + /// `SaveTo` writes a valid v2 .fl0; `EncodeBytes` yields recovery bytes decoding + /// to the identical board. + #[test] + fn save_and_recovery_bytes_round_trip() -> Result<()> { + let (source_board, _, gate) = drip_pair(1)?; + let io = FlowIoHandle::spawn(Arc::clone(&gate)).expect("io service"); + let dir = std::env::temp_dir().join(format!("flowstate-flow-io-{}-{}", std::process::id(), Uuid::new_v4())); + std::fs::create_dir_all(&dir)?; + let path = dir.join("saved.fl0"); + pollster::block_on(io.save_to(path.clone()))?; + let snapshot = flowstate_flow::read_fl0(&path)?; + let restored = FlowRuntime::from_snapshot(&snapshot)?; + // The receiver gate has NOT imported the drip blob here — compare against + // its own board, not the source's. + let saved_board = board(&gate); + assert_eq!(restored.board_ref(), &saved_board); + let _ = source_board; + + let recovery = pollster::block_on(io.encode_bytes())?; + let decoded = flowstate_flow::decode_fl0_snapshot(&recovery)?; + let recovered = FlowRuntime::from_snapshot(&decoded)?; + assert_eq!(recovered.board_ref(), &saved_board); + std::fs::remove_dir_all(&dir).ok(); + Ok(()) + } +} diff --git a/crates/flowstate-collab/tests/flow_runtime.rs b/crates/flowstate-collab/tests/flow_runtime.rs new file mode 100644 index 00000000..4cf0c346 --- /dev/null +++ b/crates/flowstate-collab/tests/flow_runtime.rs @@ -0,0 +1,545 @@ +//! `FlowRuntime` write-path gates (build-order step 3): single-peer intents of +//! every class, no-op/rejection zero-mutation laws, undo/redo ping-pong per +//! class, undo-meta focus restore, and publish-queue convergence. Every +//! committed intent also runs the debug board audit (maintained board == +//! fresh materialization), so these tests double as derivation-law checks. + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use flowstate_collab::flow::{FlowDocHandle, FlowPublishEvent, FlowRuntime}; + use flowstate_flow::format::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, CellId, FlowFormat, SheetId, StrokeStyle}; + use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; + use flowstate_flow::projection::FlowBoardProjection; + use gpui_flowtext::local_intents::{ + DeleteRangeIntent, InsertTextIntent, JoinParagraphsIntent, LocalIntent, SetMarksIntent, SetParagraphStyleIntent, SplitParagraphIntent, + TextAnchor, + }; + use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; + use uuid::Uuid; + + struct Fixture { + handle: Arc, + format: FlowFormat, + sheet: SheetId, + } + + fn fixture() -> Fixture { + let format = FlowFormat::policy_debate(); + let (handle, _gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("fresh flow runtime")); + let sheet = Uuid::new_v4(); + handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Case".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("create sheet"); + Fixture { handle, format, sheet } + } + + impl Fixture { + fn add_cell(&self, placement: CellPlacement) -> CellId { + let cell = Uuid::new_v4(); + self + .handle + .apply(FlowIntent::AddCell { + sheet_id: self.sheet, + cell_id: cell, + placement, + seed: CellSeed::Empty, + }) + .expect("add cell"); + cell + } + + fn board(&self) -> FlowBoardProjection { + self.handle.board_projection().expect("board projection") + } + + fn cell_ids(&self) -> Vec { + self.board().sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect() + } + + fn paragraph_id(&self, cell: CellId, ix: usize) -> gpui_flowtext::ParagraphId { + let projection = self.handle.open_cell(cell).expect("open cell"); + projection.ids.paragraph_ids[ix] + } + + fn set_content(&self, cell: CellId, texts: &[(&str, ParagraphStyle)]) { + let paragraphs = texts + .iter() + .map(|(text, style)| InputParagraph { + style: *style, + runs: vec![InputRun { + text: (*text).to_string(), + styles: RunStyles::default(), + }], + }) + .collect(); + self + .handle + .apply(FlowIntent::ReplaceCellContent { + sheet_id: self.sheet, + cell_id: cell, + paragraphs, + }) + .expect("replace cell content"); + } + + fn summary(&self, cell: CellId) -> String { + self + .board() + .cell(cell) + .map(|(_, cell)| cell.summary.summary_text.to_string()) + .expect("cell summary") + } + } + + fn stroke(sheet: SheetId, originator: &str) -> AnnotationStroke { + AnnotationStroke { + id: Uuid::new_v4(), + sheet_id: sheet, + originator: AnnotationOriginator(originator.into()), + points: vec![BoardPoint { x: 0.0, y: 0.0 }, BoardPoint { x: 1.0, y: 1.0 }], + style: StrokeStyle { + color_rgba: 0xff00_00ff, + width: 2.0, + opacity: 0.5, + }, + bbox: BoardRect { + min: BoardPoint { x: 0.0, y: 0.0 }, + max: BoardPoint { x: 1.0, y: 1.0 }, + }, + } + } + + #[test] + fn structural_intents_maintain_the_board_in_place() { + let fx = fixture(); + // Placements: end, top, sibling, first/last response. + let first = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let top = fx.add_cell(CellPlacement::ColumnTop { column_index: 0 }); + let response = fx.add_cell(CellPlacement::ResponseTo { parent: first }); + let first_response = fx.add_cell(CellPlacement::FirstResponseTo { parent: first }); + let sibling = fx.add_cell(CellPlacement::Sibling { + of: first, + position: RelativePosition::After, + }); + assert_eq!(fx.cell_ids(), vec![top, first, first_response, response, sibling]); + let board = fx.board(); + assert_eq!(board.sheets[0].cell(response).unwrap().parent_id, Some(first)); + assert_eq!(board.sheets[0].cell(first_response).unwrap().parent_id, Some(first)); + assert_eq!(board.sheets[0].cell(sibling).unwrap().parent_id, None); + + // Subtree move: `first` (with its two responses) after `sibling`. + fx.handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: first, + drop: FlowDropIntent::AfterSibling(sibling), + }) + .expect("move subtree"); + assert_eq!(fx.cell_ids(), vec![top, sibling, first, first_response, response]); + + // Rename + move sheet + second sheet. + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Renamed".into(), + }) + .expect("rename"); + let second_sheet = Uuid::new_v4(); + fx.handle + .apply(FlowIntent::CreateSheet { + sheet_id: second_sheet, + name: "Other".into(), + sheet_type_id: fx.format.sheet_types[1].id, + }) + .expect("second sheet"); + fx.handle + .apply(FlowIntent::MoveSheet { + sheet_id: second_sheet, + target_index: 0, + }) + .expect("move sheet"); + let board = fx.board(); + assert_eq!( + board + .sheets + .iter() + .map(|sheet| sheet.id) + .collect::>(), + vec![second_sheet, fx.sheet] + ); + assert_eq!(board.sheet(fx.sheet).unwrap().name, "Renamed"); + + // Delete a parent: children orphan canonically (no defects on rebuild). + fx.handle + .apply(FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: first, + }) + .expect("delete cell"); + let board = fx.board(); + assert_eq!( + board + .sheet(fx.sheet) + .unwrap() + .cell(response) + .unwrap() + .parent_id, + None + ); + assert_eq!( + board + .sheet(fx.sheet) + .unwrap() + .cell(first_response) + .unwrap() + .parent_id, + None + ); + + fx.handle + .apply(FlowIntent::DeleteSheet { sheet_id: second_sheet }) + .expect("delete sheet"); + assert_eq!(fx.board().sheets.len(), 1); + } + + #[test] + fn cell_text_intents_drive_the_cell_stream_and_summary() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let authority = fx.handle.cell_authority(cell); + let projection = fx.handle.open_cell(cell).expect("open cell"); + assert_eq!(projection.paragraphs.len(), 1); + let paragraph = projection.ids.paragraph_ids[0]; + + use gpui_flowtext::local_intents::LocalWriteAuthority as _; + // Typing. + let outcome = authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, 0), + text: "Warming oceans".into(), + style_override: None, + })) + .expect("insert text"); + let commit = outcome.commit(); + assert!(commit.selection_after.is_some(), "caret snapshot rides the outcome"); + let items = authority.drain_projection_stream().expect("drain"); + assert!(!items.is_empty(), "whole-cell Replace queued for the editor"); + assert_eq!(fx.summary(cell), "Warming oceans", "board summary refreshed"); + + // Split + style + marks. + authority + .apply(LocalIntent::SplitParagraph(SplitParagraphIntent { + at: TextAnchor::new(paragraph, 7), + inherited_style: ParagraphStyle::Normal, + })) + .expect("split"); + let projection = authority.canonical_projection().expect("projection"); + assert_eq!(projection.paragraphs.len(), 2); + let second = projection.ids.paragraph_ids[1]; + authority + .apply(LocalIntent::SetParagraphStyle(SetParagraphStyleIntent { + paragraph: second, + style: flowstate_document::PARAGRAPH_ANALYTIC, + })) + .expect("style"); + authority + .apply(LocalIntent::SetMarks(SetMarksIntent { + start: TextAnchor::new(paragraph, 0), + end: TextAnchor::new(paragraph, 4), + styles: RunStyles { + strikethrough: true, + ..RunStyles::default() + }, + })) + .expect("marks"); + let projection = authority.canonical_projection().expect("projection"); + assert!( + projection.paragraphs[0] + .runs + .iter() + .any(|run| run.styles.strikethrough) + ); + assert_eq!(projection.paragraphs[1].style, flowstate_document::PARAGRAPH_ANALYTIC); + + // Join back + delete a range. + authority + .apply(LocalIntent::JoinParagraphs(JoinParagraphsIntent { first: paragraph, second })) + .expect("join"); + let projection = authority.canonical_projection().expect("projection"); + assert_eq!(projection.paragraphs.len(), 1); + authority + .apply(LocalIntent::DeleteRange(DeleteRangeIntent { + start: TextAnchor::new(paragraph, 0), + end: TextAnchor::new(paragraph, 8), + })) + .expect("delete range"); + assert_eq!(fx.summary(cell), "oceans"); + } + + #[test] + fn strike_and_editable_and_annotations() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("argument", ParagraphStyle::Normal)]); + assert!( + !fx + .board() + .cell(cell) + .unwrap() + .1 + .summary + .uses_summary_projection + ); + + // Strike on, idempotent no-op, strike off. + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike"); + assert!(fx.board().cell(cell).unwrap().1.summary.struck); + let outcome = fx + .handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike no-op"); + assert!(!outcome.changed, "same-state strike resolves to a no-op"); + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: false, + }) + .expect("unstrike"); + assert!(!fx.board().cell(cell).unwrap().1.summary.struck); + + // EnsureCellEditable restyles the first paragraph to the tag style. + fx.handle + .apply(FlowIntent::EnsureCellEditable { + sheet_id: fx.sheet, + cell_id: cell, + }) + .expect("ensure editable"); + assert!( + fx.board() + .cell(cell) + .unwrap() + .1 + .summary + .uses_summary_projection + ); + + // Annotations: add two originators, clear one, delete respects ownership. + let mine = stroke(fx.sheet, "me"); + let theirs = stroke(fx.sheet, "them"); + for annotation in [&mine, &theirs] { + fx.handle + .apply(FlowIntent::AddAnnotation { + sheet_id: fx.sheet, + stroke: annotation.clone(), + }) + .expect("add annotation"); + } + let refused = fx + .handle + .apply(FlowIntent::DeleteAnnotation { + sheet_id: fx.sheet, + stroke_id: theirs.id, + originator: AnnotationOriginator("me".into()), + }) + .expect("delete annotation (foreign)"); + assert!(!refused.changed, "cannot delete another originator's stroke"); + fx.handle + .apply(FlowIntent::ClearAnnotations { + sheet_id: Some(fx.sheet), + originator: AnnotationOriginator("me".into()), + }) + .expect("clear annotations"); + let board = fx.board(); + assert_eq!(board.sheets[0].annotations.len(), 1); + assert_eq!(board.sheets[0].annotations[0].originator, AnnotationOriginator("them".into())); + } + + #[test] + fn rejections_and_no_ops_leave_the_frontier_untouched() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + let frontier = fx.handle.board_projection().expect("board"); + let before = fx + .handle + .apply(FlowIntent::ClearAnnotations { + sheet_id: None, + originator: AnnotationOriginator("nobody".into()), + }) + .expect("clear nothing"); + assert!(!before.changed); + + // Unknown sheet/cell reject. + assert!( + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: Uuid::new_v4(), + name: "ghost".into(), + }) + .is_err() + ); + assert!( + fx.handle + .apply(FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: Uuid::new_v4(), + }) + .is_err() + ); + // Self-drop move rejects. + assert!( + fx.handle + .apply(FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: cell, + drop: FlowDropIntent::LastChildOf(cell), + }) + .is_err() + ); + assert_eq!(fx.handle.board_projection().expect("board"), frontier, "no mutation escaped"); + } + + #[test] + fn undo_redo_ping_pong_across_intent_classes() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("tag line", flowstate_document::PARAGRAPH_TAG)]); + let other = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + + // A representative mutation per class, applied in sequence. + let steps: Vec = vec![ + FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Undoable".into(), + }, + FlowIntent::MoveCellSubtree { + sheet_id: fx.sheet, + cell_id: other, + drop: FlowDropIntent::BeforeSibling(cell), + }, + FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }, + FlowIntent::AddAnnotation { + sheet_id: fx.sheet, + stroke: stroke(fx.sheet, "me"), + }, + FlowIntent::DeleteCell { + sheet_id: fx.sheet, + cell_id: other, + }, + FlowIntent::CellText { + cell_id: cell, + intent: LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(fx.paragraph_id(cell, 0), 0), + text: "typed ".into(), + style_override: None, + }), + }, + ]; + + let mut boards = vec![fx.board()]; + for step in steps { + fx.handle.apply(step).expect("apply step"); + boards.push(fx.board()); + } + + // Undo all the way down the stack, checking each intermediate board. + for expected in boards.iter().rev().skip(1) { + let outcome = fx.handle.undo().expect("undo"); + assert!(outcome.applied, "undo stack must not run dry early"); + assert_eq!(&fx.board(), expected, "undo restored the exact prior board"); + } + // Redo all the way back up. + for expected in boards.iter().skip(1) { + let outcome = fx.handle.redo().expect("redo"); + assert!(outcome.applied, "redo stack must not run dry early"); + assert_eq!(&fx.board(), expected, "redo restored the exact next board"); + } + } + + #[test] + fn undo_meta_restores_board_focus_context() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.handle + .set_undo_context(flowstate_collab::flow::runtime::FlowUndoMeta { + active_sheet: Some(fx.sheet), + focused_cell: Some(cell), + head_cursor: None, + anchor_cursor: None, + }); + fx.handle + .apply(FlowIntent::SetCellStruck { + sheet_id: fx.sheet, + cell_id: cell, + struck: true, + }) + .expect("strike under context"); + fx.set_content(cell, &[("x", ParagraphStyle::Normal)]); // later commit with same context + let outcome = fx.handle.undo().expect("undo"); + assert!(outcome.applied); + let meta = outcome.meta.expect("undo meta restored"); + assert_eq!(meta.active_sheet, Some(fx.sheet)); + assert_eq!(meta.focused_cell, Some(cell)); + } + + #[test] + fn publish_queue_converges_a_second_runtime() { + let fx = fixture(); + let cell = fx.add_cell(CellPlacement::ColumnEnd { column_index: 0 }); + fx.set_content(cell, &[("shared", flowstate_document::PARAGRAPH_TAG)]); + + // Bootstrap peer B from a snapshot, then apply more local edits on A. + let snapshot = fx + .handle + .with_test_runtime(|runtime| runtime.snapshot().expect("snapshot")); + let (peer_b, _gate_b) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot).expect("peer B")); + fx.handle + .apply(FlowIntent::RenameSheet { + sheet_id: fx.sheet, + name: "Post-snapshot".into(), + }) + .expect("rename"); + + // Drain A's publish queue and import the update bytes into B. + let events = fx + .handle + .with_test_runtime(FlowRuntime::take_pending_publish); + let mut imported = false; + for event in events { + if let FlowPublishEvent::LocalUpdate { bytes, .. } = event { + peer_b.with_test_runtime(|runtime| { + runtime + .import_remote_updates(&[bytes.as_slice()]) + .expect("import"); + }); + imported = true; + } + } + assert!(imported, "local commits queued publishable updates"); + assert_eq!(peer_b.board_projection().expect("board"), fx.board(), "peers converged"); + } +} diff --git a/crates/flowstate-collab/tests/swarm_loopback.rs b/crates/flowstate-collab/tests/swarm_loopback.rs index 309e8504..de6d895e 100644 --- a/crates/flowstate-collab/tests/swarm_loopback.rs +++ b/crates/flowstate-collab/tests/swarm_loopback.rs @@ -130,7 +130,13 @@ async fn swarm_loopback_net_subset() -> Result<()> { // owner proxy: C can authenticate directly to B and bootstrap from B even // if A is unavailable. install_snapshot_handler(&b, session, b"snapshot-from-b".to_vec()).await; - let b_invite = SessionTicket::new(session, vec![b_addr.clone()], "Shared brief".into(), admission.clone()); + let b_invite = SessionTicket::new( + session, + vec![b_addr.clone()], + "Shared brief".into(), + admission.clone(), + flowstate_collab::ticket::DocumentKind::RichText, + ); let b_invite = SessionTicket::decode_text(&b_invite.encode_text())?; let snapshot = direct::pull_with_endpoint( &c.endpoint, @@ -209,6 +215,90 @@ async fn swarm_loopback_net_subset() -> Result<()> { Ok(()) } +/// Flow-variant direct serving (flow spec Part C): a session whose handler +/// carries `SyncIoHandle::Flow` serves snapshot/updates pulls straight off the +/// flow I/O service — the identical transport surface the rich-text arm uses. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires local UDP loopback and real iroh gossip timing"] +async fn flow_direct_snapshot_and_updates_pull() -> Result<()> { + use flowstate_collab::SyncIoHandle; + use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowRuntime}; + use flowstate_flow::intents::FlowIntent; + + let session = SessionId::new(); + let lookup = MemoryLookup::new(); + let a = Peer::spawn(lookup.clone()).await?; + let b = Peer::spawn(lookup.clone()).await?; + let admission = SessionAdmission::generate(); + for peer in [&a, &b] { + peer + .direct_state + .auth() + .configure_session(session, admission.clone()); + } + auth::install_local_admission(session, admission.clone()); + let a_addr = wait_addr(&a.endpoint).await; + let b_addr = wait_addr(&b.endpoint).await; + for addr in [a_addr.clone(), b_addr.clone()] { + lookup.add_endpoint_info(addr); + } + + // A hosts a real flow runtime behind its flow I/O service. + let format = flowstate_flow::FlowFormat::policy_debate(); + let (handle, gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("flow runtime")); + let sheet = uuid::Uuid::new_v4(); + handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Served".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("sheet"); + let expected_board = handle.board_projection().expect("board"); + let vv_at_snapshot = handle.with_test_runtime(|runtime| runtime.oplog_version_vector()); + let flow_io = FlowIoHandle::spawn(std::sync::Arc::clone(&gate)).expect("flow io"); + let (request_tx, _request_rx) = async_channel::unbounded(); + a.direct_state + .register_handler(session, DirectSessionHandler::new(request_tx, Some(SyncIoHandle::Flow(flow_io)))) + .await; + + // B pulls the snapshot and reconstructs the identical board. + let snapshot = direct::pull_with_endpoint(&b.endpoint, DirectRequest::Snapshot { session }, vec![a_addr.id], Duration::from_secs(5)).await?; + let joined = FlowRuntime::from_snapshot(&snapshot).expect("joiner runtime"); + assert_eq!(joined.board_ref(), &expected_board); + + // A commits more; B pulls incremental updates for its version vector. + handle + .apply(FlowIntent::RenameSheet { + sheet_id: sheet, + name: "Renamed after snapshot".into(), + }) + .expect("rename"); + let updates = direct::pull_with_endpoint( + &b.endpoint, + DirectRequest::Updates { + session, + have_vv: vv_at_snapshot.encode(), + }, + vec![a_addr.id], + Duration::from_secs(5), + ) + .await?; + ensure!(!updates.is_empty(), "updates pull returned no bytes"); + let mut joined = joined; + joined.import_remote_updates(&[updates.as_slice()])?; + assert_eq!( + joined + .board_ref() + .sheet(sheet) + .map(|sheet| sheet.name.as_str()), + Some("Renamed after snapshot") + ); + a.router.shutdown().await.ok(); + b.router.shutdown().await.ok(); + Ok(()) +} + async fn install_snapshot_handler(peer: &Peer, session: SessionId, snapshot: Vec) { let (request_tx, request_rx) = async_channel::unbounded(); peer diff --git a/crates/flowstate-document/src/lib.rs b/crates/flowstate-document/src/lib.rs index 76708a15..cf0f7b5c 100644 --- a/crates/flowstate-document/src/lib.rs +++ b/crates/flowstate-document/src/lib.rs @@ -13,8 +13,9 @@ pub use loro_import::{ ImportedLoroDocument, document_to_loro, import_document_projection, import_paragraphs_as_loro, write_imported_document_as_loro_db8, }; pub use loro_projection::{ - ChangedContainerOwner, RegionRows, document_from_loro, document_from_loro_with_defects, materialize_body_region, materialize_table_block, - object_input_blocks_for_ids, object_input_blocks_from_loro, owner_of_changed_container, section_page_attrs, + ChangedContainerOwner, RegionRows, document_from_loro, document_from_loro_with_defects, materialize_body_region, materialize_single_flow, + materialize_table_block, object_input_blocks_for_ids, object_input_blocks_from_loro, owner_of_changed_container, paragraph_ids_by_boundary_in, + section_page_attrs, }; pub use loro_schema::{ BLOCKS_BY_ID, BODY_FLOW_ID, COMMENTS_BY_ID, FLOW_ATTRS_KEY, FLOW_ID_KEY, FLOW_KIND_KEY, FLOW_TEXT_KEY, FLOWS_BY_ID, MAIN_BODY_BLOCK_ID, @@ -25,9 +26,9 @@ pub use loro_schema::{ SECTION_ATTR_PAGE_NUMBERING_START, SECTION_ATTR_PAGE_WIDTH, SECTIONS_BY_ID, SENTINEL_NEWLINE, SectionMargins, SectionOrientation, SectionPageAttrs, SectionPageNumbering, SectionPageSize, TABLE_CELLS_BY_ID, TABLE_COLUMN_ORDER, TABLE_COLUMNS_BY_ID, TABLE_KEY, TABLE_ROW_ORDER, TABLE_ROWS_BY_ID, USERS_BY_ID, cell_flow_loro_id, cell_loro_id, cell_loro_id_for, column_loro_id, document_id, - document_schema_version, ensure_section, fork_document_lineage, init_loro_document, new_loro_document, parse_cell_loro_id, - parse_column_loro_id, parse_row_loro_id, read_section_page_attrs, record_revision, register_replica, register_user, row_loro_id, - set_section_page_attrs, touch_document_metadata, + document_schema_version, ensure_flow, ensure_section, ensure_sentinel, fork_document_lineage, init_loro_document, new_loro_document, + parse_cell_loro_id, parse_column_loro_id, parse_row_loro_id, read_section_page_attrs, record_revision, register_replica, register_user, + row_loro_id, set_section_page_attrs, touch_document_metadata, }; pub use package::{ AssetChunk, ChunkRef, DEFAULT_UPDATE_SEGMENT_COMPACTION_THRESHOLD, DocumentPackage, DocumentPackageManifest, IntegrityIndexEntry, diff --git a/crates/flowstate-document/src/loro_projection.rs b/crates/flowstate-document/src/loro_projection.rs index 1219947e..0146e80f 100644 --- a/crates/flowstate-document/src/loro_projection.rs +++ b/crates/flowstate-document/src/loro_projection.rs @@ -150,6 +150,71 @@ pub fn materialize_viewport(doc: &LoroDoc, start_unicode: usize, end_unicode: us materialize_body_region(doc, sentinel, end, ¶graph_map, &pblock_map, &object_map) } +/// Materialize ONE standalone flow — a flow map shaped by the canonical +/// `ensure_flow` (sentinel text + `MARK_*` styles) that carries its OWN +/// `paragraphs_by_id` registry as a child, e.g. a .fl0 cell's rich text. Runs +/// the SAME flow walk as the body materialization, so coalescing, style +/// defaults, fabrication, and defect reporting are one law. Paragraph-only: +/// there is no object registry, so a stray U+FFFC is skipped exactly like the +/// .db8 table-cell walk; block ids are derived 1:1 from the paragraph ids (a +/// standalone flow's blocks ARE its paragraphs). `flow_id` labels defects and +/// must not be the body flow id. +pub fn materialize_single_flow(doc: &LoroDoc, flow_map: &LoroMap, flow_id: &str) -> io::Result { + debug_assert_ne!(flow_id, ROOT_BODY_FLOW_ID, "standalone flow materialization is not the body path"); + // §28 shape: prefer direct resolution via the flow's stored raw container id, + // falling back to map-key traversal (same law as `Projector::flow_text`). + let text = match map_string_opt(flow_map, "text_container_id")?.and_then(|id| resolve_text_by_container_id(doc, &id)) { + Some(text) => text, + None => child_text(flow_map, FLOW_TEXT_KEY)?.ok_or_else(|| invalid(format!("flow `{flow_id}` has no text")))?, + }; + let paragraph_index = match child_map(flow_map, PARAGRAPHS_BY_ID)? { + Some(paragraphs) => paragraph_ids_by_boundary_in(doc, ¶graphs, &text), + None => FxHashMap::default(), + }; + let delta = crate::streaming_delta::streaming_to_delta(&text); + let mut blocks = Vec::new(); + let mut paragraph_ids = Vec::new(); + let mut defects = Vec::new(); + let no_objects = BTreeMap::new(); + Projector::walk_flow_delta( + &text, + delta, + 0, + &no_objects, + flow_id, + Some(¶graph_index), + None, + Some(&mut paragraph_ids), + None, + &mut blocks, + &mut defects, + false, + |_, _| Err(invalid("standalone flow has no object blocks")), + )?; + if paragraph_ids.is_empty() { + // Same deterministic empty-projection placeholder as the body path: the + // editor's document assembly would otherwise mint a RANDOM id for the + // mandatory trailing paragraph, so two rebuilds of the same flow disagreed. + blocks.push(InputBlock::Paragraph(InputParagraph { + style: gpui_flowtext::ParagraphStyle::Normal, + runs: Vec::new(), + })); + defects.push(ProjectionDefect::MissingParagraphMetadata { + flow_id: flow_id.to_string(), + boundary_unicode: None, + fabricated_id: loro_id_u128("paragraph.projection.empty"), + }); + paragraph_ids.push(ParagraphId(loro_id_u128("paragraph.projection.empty"))); + } + let block_ids = paragraph_ids.iter().map(|id| BlockId(id.0)).collect(); + Ok(RegionRows { + blocks, + paragraph_ids, + block_ids, + defects, + }) +} + /// §act-four M4 (cold scroll): the body-unicode position of every block's /// leading boundary, in block order — paragraph leading `\n`s plus object /// U+FFFC placeholders, sorted + deduped. Persisted in the package manifest so @@ -1388,11 +1453,21 @@ fn push_paragraph_projection_metadata( /// except boundary 0 always prefers `ROOT_FIRST_PARAGRAPH_ID` when it anchors there. #[hotpath::measure] fn paragraph_ids_by_boundary(doc: &LoroDoc, text: &LoroText) -> FxHashMap { - let mut index: FxHashMap = FxHashMap::default(); let root = doc.get_map(ROOT); let Some(paragraphs) = child_map(&root, PARAGRAPHS_BY_ID).ok().flatten() else { - return index; + return FxHashMap::default(); }; + paragraph_ids_by_boundary_in(doc, ¶graphs, text) +} + +/// [`paragraph_ids_by_boundary`], parameterized over the paragraph registry: the +/// same single-pass boundary→record-key index for a flow whose paragraph records +/// live in `paragraphs` instead of the document-root `PARAGRAPHS_BY_ID` — e.g. a +/// .fl0 cell's per-cell registry. Cursor decoding already filters on `text`'s +/// container id, so foreign records in a shared registry are skipped either way. +#[hotpath::measure] +pub fn paragraph_ids_by_boundary_in(doc: &LoroDoc, paragraphs: &LoroMap, text: &LoroText) -> FxHashMap { + let mut index: FxHashMap = FxHashMap::default(); // §act-five P4: ONE pass over the paragraph registry. The old shape scanned it // TWICE (once inside `boundary_cursor_positions`, once in the loop) and decoded // each cursor twice (there, then again in `live_cursor_pos`). Gather the decoded @@ -1402,11 +1477,11 @@ fn paragraph_ids_by_boundary(doc: &LoroDoc, text: &LoroText) -> FxHashMap, Option)> = Vec::with_capacity(sorted_keys.len()); let mut ids: Vec = Vec::new(); for key in sorted_keys { - let Some(record) = child_map(¶graphs, &key).ok().flatten() else { + let Some(record) = child_map(paragraphs, &key).ok().flatten() else { continue; }; let decode = |field: &str| { @@ -2348,4 +2423,177 @@ mod tests { ); Ok(()) } + + /// Seed a STANDALONE flow (the .fl0 cell shape): canonical `ensure_flow` map + /// inside `registry`, sentinel + first paragraph record in the flow's OWN + /// `paragraphs_by_id` child — the exact shape `materialize_single_flow` reads. + fn seed_standalone_flow(registry: &LoroMap, flow_id: &str) -> (LoroMap, LoroText, LoroMap) { + let flow = crate::loro_schema::ensure_flow(registry, flow_id, "flow-cell").expect("ensure flow"); + let text = flow + .ensure_mergeable_text(crate::loro_schema::FLOW_TEXT_KEY) + .expect("flow text"); + crate::loro_schema::ensure_sentinel(&text).expect("sentinel"); + let paragraphs = flow + .ensure_mergeable_map(PARAGRAPHS_BY_ID) + .expect("paragraph registry"); + standalone_paragraph_record(¶graphs, &text, ROOT_FIRST_PARAGRAPH_ID, flow_id, 0); + (flow, text, paragraphs) + } + + fn standalone_paragraph_record(paragraphs: &LoroMap, text: &LoroText, key: &str, flow_id: &str, boundary: usize) { + let record = paragraphs + .ensure_mergeable_map(key) + .expect("paragraph record"); + record.insert("id", key).expect("record id"); + record.insert("flow_id", flow_id).expect("record flow id"); + let cursor = text + .get_cursor(boundary, Side::Left) + .expect("boundary cursor"); + record + .insert("boundary_cursor", cursor.encode()) + .expect("boundary cursor bytes"); + record + .insert("start_cursor", cursor.encode()) + .expect("start cursor bytes"); + } + + /// The .fl0 cell round trip: a standalone flow with styled multi-paragraph + /// content materializes to the exact paragraph rows, styles, run marks, and + /// durable identities — deterministically across rebuilds. + #[test] + fn materialize_single_flow_round_trips_styled_cell_text() -> io::Result<()> { + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + let registry = doc.get_map("test.cell_flows"); + let flow_id = "cell.7.flow"; + let (flow, text, paragraphs) = seed_standalone_flow(®istry, flow_id); + + // "\n" (sentinel, Normal) + "Hello wörld" + "\n" (Custom(3) tag row) + "café tail". + text + .insert(1, "Hello wörld") + .expect("insert first paragraph"); + let boundary = text.len_unicode(); + text.insert(boundary, "\n").expect("insert boundary"); + text + .mark(boundary..boundary + 1, MARK_PARAGRAPH_STYLE, 4_i64) + .expect("mark boundary style"); // slot value 4 → Custom(3) + standalone_paragraph_record(¶graphs, &text, "paragraph.row2", flow_id, boundary); + text + .insert(boundary + 1, "café tail") + .expect("insert second paragraph"); + // Strikethrough over "wörld" (unicode 7..12) — the strike_cell shape. + text + .mark(7..12, MARK_STRIKETHROUGH, true) + .expect("mark strikethrough"); + doc.commit(); + + let rows = materialize_single_flow(&doc, &flow, flow_id)?; + assert_eq!(rows.defects, Vec::new(), "well-formed flow materializes without defects"); + assert_eq!(rows.blocks.len(), 2, "two paragraph rows"); + let InputBlock::Paragraph(first) = &rows.blocks[0] else { + panic!("first row is a paragraph"); + }; + assert_eq!(first.style, gpui_flowtext::ParagraphStyle::Normal); + assert_eq!( + first + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + "Hello wörld" + ); + assert!( + first + .runs + .iter() + .any(|run| run.styles.strikethrough && run.text == "wörld"), + "strikethrough mark survives the round trip" + ); + let InputBlock::Paragraph(second) = &rows.blocks[1] else { + panic!("second row is a paragraph"); + }; + assert_eq!(second.style, gpui_flowtext::ParagraphStyle::Custom(3)); + assert_eq!( + second + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + "café tail" + ); + // Durable identities: the seeded first record + the explicit second record, + // and block ids derived 1:1 from them. + assert_eq!( + rows.paragraph_ids, + vec![ + ParagraphId(loro_id_u128(ROOT_FIRST_PARAGRAPH_ID)), + ParagraphId(loro_id_u128("paragraph.row2")), + ] + ); + assert_eq!(rows.block_ids, vec![BlockId(rows.paragraph_ids[0].0), BlockId(rows.paragraph_ids[1].0)]); + + // Pure function: a second materialization is identical. + let again = materialize_single_flow(&doc, &flow, flow_id)?; + assert_eq!(again.blocks, rows.blocks); + assert_eq!(again.paragraph_ids, rows.paragraph_ids); + assert_eq!(again.block_ids, rows.block_ids); + + // And the rows assemble into a full per-cell DocumentProjection. + let projection = document_from_input_blocks(DocumentTheme::clone(&flowstate_document_theme()), rows.blocks); + assert_eq!(projection.paragraphs.len(), 2); + Ok(()) + } + + /// An EMPTY (even unseeded) standalone flow materializes the deterministic + /// placeholder row with a fabricated identity + defect — never a random id. + #[test] + fn materialize_single_flow_empty_flow_is_deterministic() -> io::Result<()> { + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + let registry = doc.get_map("test.cell_flows"); + let flow = crate::loro_schema::ensure_flow(®istry, "cell.9.flow", "flow-cell").expect("ensure flow"); + doc.commit(); + + let rows = materialize_single_flow(&doc, &flow, "cell.9.flow")?; + assert_eq!(rows.blocks.len(), 1, "placeholder paragraph"); + assert!(matches!(&rows.blocks[0], InputBlock::Paragraph(paragraph) if paragraph.runs.is_empty())); + assert_eq!(rows.paragraph_ids, vec![ParagraphId(loro_id_u128("paragraph.projection.empty"))]); + assert_eq!(rows.block_ids, vec![BlockId(loro_id_u128("paragraph.projection.empty"))]); + assert!( + rows + .defects + .iter() + .any(|defect| matches!(defect, ProjectionDefect::MissingParagraphMetadata { .. })) + ); + let again = materialize_single_flow(&doc, &flow, "cell.9.flow")?; + assert_eq!(again.blocks, rows.blocks); + assert_eq!(again.paragraph_ids, rows.paragraph_ids); + Ok(()) + } + + /// Registry parameterization: `paragraph_ids_by_boundary_in` reads ONLY the + /// given registry, and cursor container filtering keeps two standalone flows' + /// records from cross-talking even in a shared registry. + #[test] + fn paragraph_ids_by_boundary_in_scopes_to_the_given_registry() -> io::Result<()> { + let doc = LoroDoc::new(); + crate::loro_schema::configure_text_styles(&doc); + let registry = doc.get_map("test.cell_flows"); + let (_flow_a, text_a, paragraphs_a) = seed_standalone_flow(®istry, "cell.a.flow"); + let (_flow_b, text_b, paragraphs_b) = seed_standalone_flow(®istry, "cell.b.flow"); + text_a.insert(1, "alpha").expect("insert a"); + text_b.insert(1, "beta").expect("insert b"); + // A record for flow B written into flow A's registry: its cursor targets + // B's container, so A's index must skip it. + standalone_paragraph_record(¶graphs_a, &text_b, "paragraph.foreign", "cell.b.flow", 0); + doc.commit(); + + let index_a = paragraph_ids_by_boundary_in(&doc, ¶graphs_a, &text_a); + assert_eq!(index_a.len(), 1); + assert_eq!(index_a.get(&0).map(String::as_str), Some(ROOT_FIRST_PARAGRAPH_ID)); + let index_b = paragraph_ids_by_boundary_in(&doc, ¶graphs_b, &text_b); + assert_eq!(index_b.len(), 1); + assert_eq!(index_b.get(&0).map(String::as_str), Some(ROOT_FIRST_PARAGRAPH_ID)); + Ok(()) + } } diff --git a/crates/flowstate-document/src/loro_schema.rs b/crates/flowstate-document/src/loro_schema.rs index 7f4c188a..6e62f2df 100644 --- a/crates/flowstate-document/src/loro_schema.rs +++ b/crates/flowstate-document/src/loro_schema.rs @@ -700,7 +700,7 @@ fn init_meta(meta: &LoroMap, title: &str) -> LoroResult<()> { Ok(()) } -fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult { +pub fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult { let flow = flows.ensure_mergeable_map(flow_id)?; flow.insert(FLOW_ID_KEY, flow_id)?; flow.insert(FLOW_KIND_KEY, kind)?; @@ -710,7 +710,7 @@ fn ensure_flow(flows: &LoroMap, flow_id: &str, kind: &str) -> LoroResult LoroResult<()> { +pub fn ensure_sentinel(text: &LoroText) -> LoroResult<()> { if text.len_unicode() == 0 || !text.to_string().starts_with(SENTINEL_NEWLINE) { text.insert(0, SENTINEL_NEWLINE)?; text.mark(0..1, MARK_PARAGRAPH_STYLE, 0_i64)?; diff --git a/crates/flowstate-flow/Cargo.toml b/crates/flowstate-flow/Cargo.toml index 50935679..f88702d7 100644 --- a/crates/flowstate-flow/Cargo.toml +++ b/crates/flowstate-flow/Cargo.toml @@ -9,14 +9,17 @@ workspace = true [dependencies] anyhow = { workspace = true } -hotpath = { workspace = true } +flowstate-document.workspace = true +gpui-flowtext = { workspace = true } +hotpath.workspace = true +loro = "1.13.1" +postcard = "1.1.3" rustc-hash = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } -uuid = { workspace = true } +serde = { workspace = true, features = ["derive"] } +uuid = { workspace = true, features = ["serde", "v4"] } +zstd.workspace = true [features] -hotpath = ["hotpath/hotpath"] -hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"] -hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"] +hotpath = ["hotpath/hotpath", "flowstate-document/hotpath", "gpui-flowtext/hotpath"] +hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "gpui-flowtext/hotpath-cpu"] +hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "gpui-flowtext/hotpath-alloc"] diff --git a/crates/flowstate-flow/examples/dump_flow.rs b/crates/flowstate-flow/examples/dump_flow.rs new file mode 100644 index 00000000..7064bb6b --- /dev/null +++ b/crates/flowstate-flow/examples/dump_flow.rs @@ -0,0 +1,64 @@ +//! Dumps a `.fl0` (v2, Loro-first) flow's topology and full cell text +//! (including any notes typed into cells). +//! +//! Run: `cargo run -p flowstate-flow --example dump_flow -- ` + +use flowstate_flow::{loro_projection::materialize_board, loro_schema, read_fl0}; +use loro::LoroDoc; + +fn main() { + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "flow-ergonomics-fixture.fl0".to_string()); + let snapshot = read_fl0(&path).expect("read .fl0"); + let doc = LoroDoc::new(); + doc + .import_with(&snapshot, "remote") + .expect("import snapshot"); + let (board, defects) = materialize_board(&doc).expect("materialize board"); + if !defects.is_empty() { + println!("normalization defects: {defects:?}"); + } + + for sheet in &board.sheets { + let definition = board.format.sheet_type(sheet.sheet_type_id); + let column_name = |id| { + definition.and_then(|definition: &flowstate_flow::SheetTypeDefinition| { + definition + .columns + .iter() + .find(|column| column.id == id) + .map(|column| column.label.clone()) + }) + }; + let label = |id| { + sheet + .cells + .iter() + .find(|cell| cell.id == id) + .map(|cell| { + cell + .summary + .summary_text + .lines() + .next() + .unwrap_or_default() + .trim() + .to_string() + }) + .unwrap_or_default() + }; + println!("\n===== SHEET: {} ({} cells) =====", sheet.name, sheet.cells.len()); + for (index, cell) in sheet.cells.iter().enumerate() { + let col = column_name(cell.column_id).unwrap_or_else(|| "?".into()); + let parent = cell.parent_id.map(label).unwrap_or_else(|| "—".into()); + let text = loro_schema::cell_text(&doc, cell.id) + .map(|text| text.to_string()) + .unwrap_or_default(); + println!("[{index:2}] col={col:<6} parent={parent}"); + for line in text.lines() { + println!(" | {line}"); + } + } + } +} diff --git a/crates/flowstate-flow/examples/ergonomics_fixture.rs b/crates/flowstate-flow/examples/ergonomics_fixture.rs new file mode 100644 index 00000000..8fba4a75 --- /dev/null +++ b/crates/flowstate-flow/examples/ergonomics_fixture.rs @@ -0,0 +1,159 @@ +//! Generates a labeled `.fl0` (v2, Loro-first) flow document for drag-and-drop +//! ergonomics testing. +//! +//! Run: `cargo run -p flowstate-flow --example ergonomics_fixture -- [out.fl0]` +//! +//! Every cell's tag text is a unique, stable label (A1, B1, …) that shows on the board and in the +//! telemetry log, so the instruction guide and `FLOWSTATE_DRAG_LOG` output line up by name. The +//! topology deliberately spans the move types the guide exercises: sibling runs, deep parent→child +//! chains, orphans mid-column, and cells of varied height. Cells are written in canonical DFS +//! order so the persisted order lists match the write-path invariant. + +use flowstate_document::{ + InputParagraph, InputRun, PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, ParagraphStyle, RunStyles, SEMANTIC_CITE, +}; +use flowstate_flow::{ + CellId, FlowFormat, SheetId, encode_fl0_snapshot, + loro_projection::materialize_board, + loro_schema::{cell_flow_from_paragraphs, init_flow_document, write_cell, write_sheet}, +}; +use loro::LoroDoc; + +#[derive(Clone, Copy)] +enum Tier { + Short, + Medium, + Tall, + Card, +} + +fn run(text: &str) -> InputRun { + InputRun { + text: text.into(), + styles: RunStyles::default(), + } +} + +fn cite(text: &str) -> InputRun { + let mut run = run(text); + run.styles.semantic = SEMANTIC_CITE; + run +} + +fn paragraph(style: ParagraphStyle, runs: Vec) -> InputParagraph { + InputParagraph { style, runs } +} + +fn content(label: &str, tier: Tier) -> Vec { + let mut paragraphs = vec![paragraph(PARAGRAPH_TAG, vec![run(label)])]; + match tier { + Tier::Short => {}, + Tier::Medium => paragraphs.push(paragraph(PARAGRAPH_UNDERTAG, vec![run(&format!("underview supporting {label}"))])), + Tier::Tall => { + paragraphs.push(paragraph(PARAGRAPH_UNDERTAG, vec![run(&format!("underview supporting {label}"))])); + paragraphs.push(paragraph( + PARAGRAPH_ANALYTIC, + vec![run(&format!( + "analytic {label}: first supporting line, long enough to wrap and add real height" + ))], + )); + paragraphs.push(paragraph( + PARAGRAPH_ANALYTIC, + vec![run(&format!("analytic {label}: second supporting line"))], + )); + }, + Tier::Card => paragraphs.push(paragraph( + ParagraphStyle::Normal, + vec![ + cite(&format!("{label} — Author 2024")), + run(" full card body that stays hidden in the summary projection"), + ], + )), + } + paragraphs +} + +struct Fixture { + doc: LoroDoc, + format: FlowFormat, + sheet_type_index: usize, + next_order: usize, +} + +impl Fixture { + fn sheet(&mut self, name: &str, sheet_type_index: usize, order_index: usize) -> SheetId { + let sheet = SheetId::new_v4(); + write_sheet(&self.doc, sheet, name, self.format.sheet_types[sheet_type_index].id, order_index).expect("write sheet"); + self.sheet_type_index = sheet_type_index; + self.next_order = 0; + sheet + } + + fn add(&mut self, sheet: SheetId, column: usize, parent: Option, label: &str, tier: Tier) -> CellId { + let id = CellId::new_v4(); + let column_id = self.format.sheet_types[self.sheet_type_index].columns[column].id; + let cell = write_cell(&self.doc, sheet, id, column_id, parent, self.next_order).expect("write cell"); + self.next_order += 1; + cell_flow_from_paragraphs(&cell, &content(label, tier)).expect("seed cell content"); + id + } +} + +fn main() { + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "flow-ergonomics-fixture.fl0".to_string()); + let doc = LoroDoc::new(); + let format = FlowFormat::policy_debate(); + init_flow_document(&doc, &format).expect("init flow document"); + let mut fixture = Fixture { + doc, + format: format.clone(), + sheet_type_index: 0, + next_order: 0, + }; + + // ---- Sheet 1: "Ergonomics" (Affirmative) — written in canonical DFS order: + // each root followed by its whole subtree, orphans as trailing roots. + let sheet = fixture.sheet("Ergonomics", 0, 0); + + // A1 with a deep chain B1 -> C1 -> D1 -> E1 -> F1, then sibling B2. + let a1 = fixture.add(sheet, 0, None, "A1", Tier::Medium); + let b1 = fixture.add(sheet, 1, Some(a1), "B1", Tier::Card); + let c1 = fixture.add(sheet, 2, Some(b1), "C1", Tier::Medium); + let d1 = fixture.add(sheet, 3, Some(c1), "D1", Tier::Short); + let e1 = fixture.add(sheet, 4, Some(d1), "E1", Tier::Short); + fixture.add(sheet, 5, Some(e1), "F1", Tier::Medium); + fixture.add(sheet, 1, Some(a1), "B2", Tier::Short); + + // A2 with a response B3 that has two children C2/C3. + let a2 = fixture.add(sheet, 0, None, "A2", Tier::Tall); + let b3 = fixture.add(sheet, 1, Some(a2), "B3", Tier::Medium); + fixture.add(sheet, 2, Some(b3), "C2", Tier::Short); + fixture.add(sheet, 2, Some(b3), "C3", Tier::Tall); + + // A3 leaf root, plus its response B5. + let a3 = fixture.add(sheet, 0, None, "A3", Tier::Short); + fixture.add(sheet, 1, Some(a3), "B5", Tier::Short); + + // Orphans (no parent) sitting in interior columns. + fixture.add(sheet, 1, None, "B4", Tier::Short); + fixture.add(sheet, 2, None, "C4", Tier::Short); + + // ---- Sheet 2: "Scratch" (Negative) ---- + let scratch = fixture.sheet("Scratch", 1, 1); + let s1 = fixture.add(scratch, 0, None, "S1", Tier::Short); + fixture.add(scratch, 1, Some(s1), "S2", Tier::Short); + fixture.add(scratch, 0, None, "S3", Tier::Medium); + + fixture.doc.commit(); + let snapshot = fixture + .doc + .export(loro::ExportMode::Snapshot) + .expect("export snapshot"); + std::fs::write(&out, encode_fl0_snapshot(&snapshot)).expect("save fixture"); + let (board, defects) = materialize_board(&fixture.doc).expect("materialize board"); + assert!(defects.is_empty(), "fixture must materialize defect-free: {defects:?}"); + let cells: usize = board.sheets.iter().map(|sheet| sheet.cells.len()).sum(); + println!("wrote {out} — {} sheets, {cells} cells", board.sheets.len()); +} diff --git a/crates/flowstate-flow/src/actions.rs b/crates/flowstate-flow/src/actions.rs deleted file mode 100644 index 927dfb91..00000000 --- a/crates/flowstate-flow/src/actions.rs +++ /dev/null @@ -1,433 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::document::{BoxNode, Flow, FlowDocument, Node, NodeId, NodeValue, Nodes, ROOT_ID, constrain_index, new_box_id, new_flow_id}; -use crate::styles::DebateStyleFlow; - -pub type ActionBundle = Vec; - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(tag = "tag")] -pub enum Action { - #[serde(rename = "add")] - Add { - parent: NodeId, - id: NodeId, - index: usize, - value: NodeValue, - }, - #[serde(rename = "delete")] - Delete { id: NodeId }, - #[serde(rename = "update")] - Update { - id: NodeId, - #[serde(rename = "newValue")] - new_value: NodeValue, - }, - #[serde(rename = "move")] - Move { - id: NodeId, - #[serde(rename = "newIndex")] - new_index: usize, - }, - #[serde(rename = "replace")] - Replace { - #[serde(rename = "newNodes")] - new_nodes: Nodes, - }, - #[serde(rename = "identity")] - Identity, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandResult { - pub actions: ActionBundle, - pub owner: NodeId, - pub focus: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FormatKind { - Bold, - Crossed, -} - -#[hotpath::measure_all] -impl FlowDocument { - pub fn apply_action(&mut self, action: Action) -> Action { - match action { - Action::Add { parent, id, index, value } => { - if self.nodes.contains_key(&id) { - return Action::Identity; - } - let Some(parent_node) = self.nodes.get(&parent) else { - return Action::Identity; - }; - let child = Node { - value, - level: parent_node.level + 1, - parent: Some(parent.clone()), - children: Vec::new(), - }; - self.nodes.insert(id.clone(), child); - let Some(parent_node) = self.nodes.get_mut(&parent) else { - return Action::Identity; - }; - let index = constrain_index(index, parent_node.children.len()); - parent_node.children.insert(index, id.clone()); - Action::Delete { id } - }, - Action::Delete { id } => { - let inverse_replace = self - .nodes - .get(&id) - .is_some_and(|node| !node.children.is_empty()) - .then(|| Action::Replace { - new_nodes: self.nodes.clone(), - }); - let Some(node) = self.nodes.get(&id).cloned() else { - return Action::Identity; - }; - let Some(parent_id) = node.parent.clone() else { - return Action::Identity; - }; - let Some(parent) = self.nodes.get_mut(&parent_id) else { - return Action::Identity; - }; - let Some(index) = parent.children.iter().position(|child| child == &id) else { - return Action::Identity; - }; - parent.children.remove(index); - remove_subtree(&mut self.nodes, &id); - inverse_replace.unwrap_or(Action::Add { - parent: parent_id, - id, - index, - value: node.value, - }) - }, - Action::Update { id, new_value } => { - let Some(node) = self.nodes.get_mut(&id) else { - return Action::Identity; - }; - let inverse = Action::Update { - id, - new_value: node.value.clone(), - }; - node.value = new_value; - inverse - }, - Action::Move { id, new_index } => { - let Some(node) = self.nodes.get(&id) else { - return Action::Identity; - }; - let Some(parent_id) = node.parent.clone() else { - return Action::Identity; - }; - let Some(parent) = self.nodes.get_mut(&parent_id) else { - return Action::Identity; - }; - let Some(index) = parent.children.iter().position(|child| child == &id) else { - return Action::Identity; - }; - parent.children.remove(index); - let index_after_remove = constrain_index(new_index, parent.children.len()); - parent.children.insert(index_after_remove, id.clone()); - Action::Move { id, new_index: index } - }, - Action::Replace { new_nodes } => { - let inverse = Action::Replace { - new_nodes: self.nodes.clone(), - }; - self.nodes = new_nodes; - inverse - }, - Action::Identity => Action::Identity, - } - } - - pub fn apply_action_bundle(&mut self, actions: ActionBundle) -> ActionBundle { - let mut inverse = Vec::with_capacity(actions.len()); - for action in actions { - inverse.push(self.apply_action(action)); - } - inverse.reverse(); - inverse - } -} - -/// Removes `id` and all of its descendants from `nodes`. -/// -/// Removing each node before recursing into its (former) children keeps the -/// walk cycle-safe: a malformed child edge pointing back at an already-removed -/// ancestor short-circuits on the `remove` miss instead of recursing forever. -#[hotpath::measure] -fn remove_subtree(nodes: &mut Nodes, id: &NodeId) { - if let Some(node) = nodes.remove(id) { - for child in &node.children { - remove_subtree(nodes, child); - } - } -} - -#[hotpath::measure] -pub fn new_box_action(parent: NodeId, parent_flow_id: NodeId, index: usize, placeholder: Option) -> Action { - Action::Add { - parent, - id: new_box_id(), - index, - value: NodeValue::Box(BoxNode { - content: String::new(), - flow_id: parent_flow_id, - placeholder, - empty: false, - crossed: false, - bold: false, - is_extension: false, - }), - } -} - -pub const fn new_extension_action(parent: NodeId, parent_flow_id: NodeId, id: NodeId) -> Action { - Action::Add { - parent, - id, - index: 0, - value: NodeValue::Box(BoxNode { - content: String::new(), - flow_id: parent_flow_id, - placeholder: None, - empty: false, - crossed: false, - bold: false, - is_extension: true, - }), - } -} - -#[must_use] -pub const fn new_update_action(id: NodeId, new_value: NodeValue) -> Action { - Action::Update { id, new_value } -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_box_actions(document: &FlowDocument, parent: NodeId, index: usize, placeholder: Option) -> Option { - let flow_id = document.parent_flow_id(&parent)?; - let action = new_box_action(parent, flow_id.clone(), index, placeholder); - let focus = match &action { - Action::Add { id, .. } => Some(id.clone()), - _ => None, - }; - Some(CommandResult { - actions: vec![action], - owner: flow_id, - focus, - }) -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_extension_actions(document: &FlowDocument, parent: NodeId) -> Option { - let flow_id = document.parent_flow_id(&parent)?; - let extension_id = new_box_id(); - let child = new_box_action(extension_id.clone(), flow_id.clone(), 0, None); - let focus = match &child { - Action::Add { id, .. } => Some(id.clone()), - _ => None, - }; - Some(CommandResult { - actions: vec![new_extension_action(parent, flow_id.clone(), extension_id), child], - owner: flow_id, - focus, - }) -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_flow_actions(index: usize, style: &DebateStyleFlow, switch_speakers: bool) -> CommandResult { - let starter_boxes = style.starter_boxes.as_deref(); - let columns = if switch_speakers { - style - .columns_switch - .clone() - .unwrap_or_else(|| style.columns.clone()) - } else { - style.columns.clone() - }; - let flow_id = new_flow_id(); - let mut actions = vec![Action::Add { - parent: ROOT_ID.to_owned(), - id: flow_id.clone(), - index, - value: NodeValue::Flow(Flow { - content: String::new(), - invert: style.invert, - columns, - }), - }]; - - if let Some(starter_boxes) = starter_boxes { - for (index, placeholder) in starter_boxes.iter().enumerate() { - actions.push(new_box_action(flow_id.clone(), flow_id.clone(), index, Some((*placeholder).clone()))); - } - } else { - actions.push(new_box_action(flow_id.clone(), flow_id.clone(), 0, None)); - } - - CommandResult { - actions, - owner: ROOT_ID.to_owned(), - focus: Some(flow_id), - } -} - -#[hotpath::measure] -#[must_use] -pub fn toggle_box_format_actions(document: &FlowDocument, id: NodeId, format: FormatKind) -> Option { - let mut box_node = document.box_node(&id)?.clone(); - match format { - FormatKind::Bold => box_node.bold = !box_node.bold, - FormatKind::Crossed => box_node.crossed = !box_node.crossed, - } - let owner = document.parent_flow_id(&id)?; - Some(CommandResult { - actions: vec![new_update_action(id.clone(), NodeValue::Box(box_node))], - owner, - focus: Some(id), - }) -} - -#[hotpath::measure] -#[must_use] -pub fn move_node_actions(document: &FlowDocument, id: NodeId, new_index: usize) -> Option { - let owner = match document.node(&id)?.value { - NodeValue::Flow(_) => ROOT_ID.to_owned(), - NodeValue::Box(_) => document.parent_flow_id(&id)?, - NodeValue::Root => return None, - }; - Some(CommandResult { - actions: vec![Action::Move { id: id.clone(), new_index }], - owner, - focus: Some(id), - }) -} - -#[hotpath::measure] -#[must_use] -pub fn delete_node_actions(document: &FlowDocument, id: NodeId) -> Option { - let owner = match document.node(&id)?.value { - NodeValue::Flow(_) => ROOT_ID.to_owned(), - NodeValue::Box(_) => document.parent_flow_id(&id)?, - NodeValue::Root => return None, - }; - let mut actions = Vec::new(); - collect_delete_actions(document, &id, &mut actions)?; - Some(CommandResult { actions, owner, focus: None }) -} - -#[hotpath::measure] -fn collect_delete_actions(document: &FlowDocument, id: &str, actions: &mut ActionBundle) -> Option<()> { - let node = document.node(id)?; - for child in node.children.iter().rev() { - collect_delete_actions(document, child, actions)?; - } - actions.push(Action::Delete { id: id.to_owned() }); - Some(()) -} - -#[hotpath::measure] -#[must_use] -pub fn add_new_empty_actions(document: &FlowDocument, flow_id: NodeId, level: usize) -> Option { - document.flow(&flow_id)?; - let mut actions = Vec::with_capacity(level + 1); - let mut parent_id = flow_id.clone(); - for _ in 0..level { - let id = new_box_id(); - actions.push(Action::Add { - parent: parent_id, - id: id.clone(), - index: 0, - value: NodeValue::Box(BoxNode { - content: String::new(), - flow_id: flow_id.clone(), - placeholder: None, - empty: true, - crossed: false, - bold: false, - is_extension: false, - }), - }); - parent_id = id; - } - let final_box = new_box_action(parent_id, flow_id.clone(), 0, None); - let focus = match &final_box { - Action::Add { id, .. } => Some(id.clone()), - _ => None, - }; - actions.push(final_box); - Some(CommandResult { - actions, - owner: flow_id, - focus, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::history::HistoryHolder; - use crate::styles::{DebateStyleKey, debate_style_templates}; - - #[test] - #[hotpath::measure] - fn add_update_delete_and_undo_round_trip() { - let mut document = FlowDocument::new(); - let template = debate_style_templates(DebateStyleKey::Policy, false).remove(0); - let add_flow = add_new_flow_actions(0, &template, false); - let flow_id = add_flow.focus.clone().unwrap(); - let inverse = document.apply_action_bundle(add_flow.actions); - let mut history = HistoryHolder::new(); - history.add(add_flow.owner, inverse, None, Some(flow_id.clone())); - - let first_box = document.node(&flow_id).unwrap().children[0].clone(); - let mut box_node = document.box_node(&first_box).unwrap().clone(); - box_node.content = "plan flaw".to_string(); - let inverse = document.apply_action_bundle(vec![new_update_action(first_box.clone(), NodeValue::Box(box_node))]); - history.add(flow_id.clone(), inverse, Some(first_box.clone()), Some(first_box.clone())); - assert_eq!(document.box_node(&first_box).unwrap().content, "plan flaw"); - - history.undo(&flow_id, &mut document); - assert_eq!(document.box_node(&first_box).unwrap().content, ""); - - history.redo(&flow_id, &mut document); - assert_eq!(document.box_node(&first_box).unwrap().content, "plan flaw"); - - let delete = delete_node_actions(&document, first_box.clone()).unwrap(); - let inverse = document.apply_action_bundle(delete.actions); - history.add(flow_id.clone(), inverse, Some(first_box.clone()), Some(flow_id.clone())); - assert!(document.box_node(&first_box).is_none()); - - history.undo(&flow_id, &mut document); - assert_eq!(document.box_node(&first_box).unwrap().content, "plan flaw"); - } - - #[test] - #[hotpath::measure] - fn move_flow_actions_reorders_root_children_and_undoes() { - let mut document = FlowDocument::new(); - let template = debate_style_templates(DebateStyleKey::Policy, false).remove(0); - let first = add_new_flow_actions(0, &template, false); - let first_id = first.focus.clone().unwrap(); - document.apply_action_bundle(first.actions); - let second = add_new_flow_actions(1, &template, false); - let second_id = second.focus.clone().unwrap(); - document.apply_action_bundle(second.actions); - - let command = move_node_actions(&document, first_id.clone(), 1).unwrap(); - let inverse = document.apply_action_bundle(command.actions); - assert_eq!(document.flow_ids(), &[second_id.clone(), first_id.clone()]); - - document.apply_action_bundle(inverse); - assert_eq!(document.flow_ids(), &[first_id, second_id]); - } -} diff --git a/crates/flowstate-flow/src/board_ops.rs b/crates/flowstate-flow/src/board_ops.rs new file mode 100644 index 00000000..af64d589 --- /dev/null +++ b/crates/flowstate-flow/src/board_ops.rs @@ -0,0 +1,376 @@ +//! Pure board operations over [`FlowBoardProjection`] (.fl0 v2 spec, Part A). +//! +//! Every function here is a pure function of projection data — no Loro, no +//! gate — so the drag preview can run one every frame, and the committing +//! runtime resolves the SAME functions inside the gate (one law for preview +//! and commit). Ported verbatim from the record-era `operations.rs`. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context as _, bail}; +use uuid::Uuid; + +use crate::format::{CellId, SheetId}; +use crate::intents::FlowDropIntent; +use crate::projection::{Cell, FlowBoardProjection, Sheet}; + +pub fn is_descendant_of(sheet: &Sheet, cell_id: CellId, ancestor_id: CellId) -> bool { + let mut parent = sheet.cell(cell_id).and_then(|cell| cell.parent_id); + while let Some(parent_id) = parent { + if parent_id == ancestor_id { + return true; + } + parent = sheet.cell(parent_id).and_then(|cell| cell.parent_id); + } + false +} + +/// The dragged cell plus every descendant, in sheet order. Empty if unknown. +#[must_use] +pub fn subtree_cell_ids(sheet: &Sheet, root_id: CellId) -> Vec { + sheet + .cells + .iter() + .filter(|cell| cell.id == root_id || is_descendant_of(sheet, cell.id, root_id)) + .map(|cell| cell.id) + .collect() +} + +pub fn sheet_column_ids(board: &FlowBoardProjection, sheet_id: SheetId) -> anyhow::Result> { + let sheet = board.sheet(sheet_id).context("unknown sheet")?; + let definition = board + .format + .sheet_type(sheet.sheet_type_id) + .context("unknown sheet type")?; + Ok(definition.columns.iter().map(|column| column.id).collect()) +} + +/// Flat insertion index appending AFTER every existing descendant of `parent`. +pub fn child_append_index(board: &FlowBoardProjection, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = board.sheet(sheet_id).context("unknown sheet")?; + let parent_index = sheet + .cells + .iter() + .position(|cell| cell.id == parent_id) + .context("unknown cell")?; + Ok( + sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent_id)) + .map(|(index, _)| index) + .max() + .unwrap_or(parent_index) + + 1, + ) +} + +/// Flat insertion index immediately after `parent` (before existing subtrees). +pub fn child_prepend_index(board: &FlowBoardProjection, sheet_id: SheetId, parent_id: CellId) -> anyhow::Result { + let sheet = board.sheet(sheet_id).context("unknown sheet")?; + let parent_index = sheet + .cells + .iter() + .position(|cell| cell.id == parent_id) + .context("unknown cell")?; + Ok(parent_index + 1) +} + +/// Focus fallback after deleting `cell_id`: previous same-column sibling, then +/// parent, then the last root of the column to the left. +#[must_use] +pub fn deletion_fallback(board: &FlowBoardProjection, sheet_id: SheetId, cell_id: CellId) -> Option { + let sheet = board.sheet(sheet_id)?; + let definition = board.format.sheet_type(sheet.sheet_type_id)?; + let index = sheet.cells.iter().position(|cell| cell.id == cell_id)?; + let cell = &sheet.cells[index]; + + if let Some(previous) = sheet.cells[..index] + .iter() + .rev() + .find(|candidate| candidate.column_id == cell.column_id && candidate.parent_id == cell.parent_id) + { + return Some(previous.id); + } + if let Some(parent) = cell.parent_id { + return Some(parent); + } + + let column = definition + .columns + .iter() + .position(|column| column.id == cell.column_id)?; + let left_column = column + .checked_sub(1) + .and_then(|index| definition.columns.get(index))? + .id; + sheet + .cells + .iter() + .rev() + .find(|candidate| candidate.column_id == left_column && candidate.parent_id.is_none()) + .map(|candidate| candidate.id) +} + +/// Cheap structural check of the two move-sensitive invariants — parent/child +/// column adjacency and contiguous sibling runs per column. A move never +/// touches cell content, so this is exactly the failure set a move can +/// introduce. +#[must_use] +pub fn sheet_topology_ok(sheet: &Sheet, column_ids: &[Uuid]) -> bool { + let level = |column: Uuid| column_ids.iter().position(|candidate| *candidate == column); + let cells: HashMap = sheet.cells.iter().map(|cell| (cell.id, cell)).collect(); + for cell in &sheet.cells { + let Some(cell_level) = level(cell.column_id) else { + return false; + }; + if let Some(parent_id) = cell.parent_id { + let Some(parent) = cells.get(&parent_id) else { + return false; + }; + let Some(parent_level) = level(parent.column_id) else { + return false; + }; + if cell_level != parent_level + 1 { + return false; + } + } + } + for &column in column_ids { + let mut completed_parents = HashSet::new(); + let mut current_parent = None; + for cell in sheet.cells.iter().filter(|cell| cell.column_id == column) { + if cell.parent_id != current_parent { + if let Some(parent) = current_parent { + completed_parents.insert(parent); + } + if cell + .parent_id + .is_some_and(|parent| completed_parents.contains(&parent)) + { + return false; + } + current_parent = cell.parent_id; + } + } + } + true +} + +/// Move `cell_id` (and its subtree) within `sheet` according to `intent`. +/// Pure over the sheet, so the same algorithm backs the committing runtime +/// AND the read-only drag previews. +pub fn apply_move_subtree(sheet: &mut Sheet, column_ids: &[Uuid], cell_id: CellId, intent: FlowDropIntent) -> anyhow::Result<()> { + let source = sheet + .cells + .iter() + .position(|cell| cell.id == cell_id) + .context("unknown cell")?; + let source_level = column_ids + .iter() + .position(|column| *column == sheet.cells[source].column_id) + .context("source cell references unknown column")?; + + let (target_column, target_index, new_parent) = resolve_drop_intent(sheet, column_ids, cell_id, intent)?; + let level_delta = target_column as isize - source_level as isize; + let subtree_ids = subtree_cell_ids(sheet, cell_id); + if let Some(parent) = new_parent + && subtree_ids.contains(&parent) + { + bail!("cannot move a cell into itself or one of its descendants"); + } + let removed_before_target = sheet + .cells + .iter() + .take(target_index.min(sheet.cells.len())) + .filter(|cell| subtree_ids.contains(&cell.id)) + .count(); + + let mut subtree = Vec::new(); + let mut remaining = Vec::with_capacity(sheet.cells.len()); + for cell in sheet.cells.drain(..) { + if subtree_ids.contains(&cell.id) { + subtree.push(cell); + } else { + remaining.push(cell); + } + } + let insertion_index = target_index + .saturating_sub(removed_before_target) + .min(remaining.len()); + + for cell in &mut subtree { + let old_level = column_ids + .iter() + .position(|column| *column == cell.column_id) + .context("subtree cell references unknown column")?; + let new_level = old_level + .checked_add_signed(level_delta) + .filter(|level| *level < column_ids.len()) + .context("moving this subtree would place a descendant outside the sheet columns")?; + cell.column_id = column_ids[new_level]; + if cell.id == cell_id { + cell.parent_id = new_parent; + } + } + remaining.splice(insertion_index..insertion_index, subtree); + sheet.cells = remaining; + Ok(()) +} + +pub fn resolve_drop_intent( + sheet: &Sheet, + column_ids: &[Uuid], + dragged: CellId, + intent: FlowDropIntent, +) -> anyhow::Result<(usize, usize, Option)> { + match intent { + FlowDropIntent::BeforeSibling(target) | FlowDropIntent::AfterSibling(target) => { + if dragged == target { + bail!("cannot drop a cell relative to itself"); + } + if is_descendant_of(sheet, target, dragged) { + bail!("cannot drop a cell relative to one of its descendants"); + } + let target_index = sheet + .cells + .iter() + .position(|cell| cell.id == target) + .context("unknown target cell")?; + let target_cell = &sheet.cells[target_index]; + let column_index = column_ids + .iter() + .position(|column| *column == target_cell.column_id) + .context("target cell references unknown column")?; + let insertion_index = match intent { + FlowDropIntent::BeforeSibling(_) => target_index, + FlowDropIntent::AfterSibling(_) => target_index + 1, + FlowDropIntent::FirstChildOf(_) | FlowDropIntent::LastChildOf(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), + }; + Ok((column_index, insertion_index, target_cell.parent_id)) + }, + FlowDropIntent::FirstChildOf(parent) | FlowDropIntent::LastChildOf(parent) => { + if dragged == parent { + bail!("cannot drop a cell onto itself"); + } + if is_descendant_of(sheet, parent, dragged) { + bail!("cannot drop a cell onto one of its descendants"); + } + let parent_index = sheet + .cells + .iter() + .position(|cell| cell.id == parent) + .context("unknown parent cell")?; + let parent_cell = &sheet.cells[parent_index]; + let parent_column = column_ids + .iter() + .position(|column| *column == parent_cell.column_id) + .context("parent cell references unknown column")?; + let child_column = parent_column + .checked_add(1) + .filter(|index| *index < column_ids.len()) + .context("parent has no child column")?; + let insertion_index = match intent { + FlowDropIntent::FirstChildOf(_) => parent_index + 1, + FlowDropIntent::LastChildOf(_) => { + sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| is_descendant_of(sheet, cell.id, parent)) + .map(|(index, _)| index) + .max() + .unwrap_or(parent_index) + + 1 + }, + FlowDropIntent::BeforeSibling(_) | FlowDropIntent::AfterSibling(_) | FlowDropIntent::RootInColumn { .. } => unreachable!(), + }; + Ok((child_column, insertion_index, Some(parent))) + }, + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + } => { + if column_index >= column_ids.len() { + bail!("column index out of range"); + } + Ok((column_index, insertion_index, None)) + }, + } +} + +/// The canonical DFS linearization (the materializer's normalization step 6 +/// as a pure function): roots in current flat order, each followed by its +/// whole subtree, children in flat order. The write path re-linearizes after +/// any structural mutation that can interleave subtrees (delete-orphaning, an +/// arbitrary-index drop), so canonical order stays an invariant of the raw +/// order list rather than a view-time repair. +#[must_use] +pub fn canonical_linearization(cells: &[Cell]) -> Vec { + let mut children: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + for cell in cells { + match cell.parent_id { + Some(parent) => children.entry(parent).or_default().push(cell.id), + None => roots.push(cell.id), + } + } + let mut canonical = Vec::with_capacity(cells.len()); + let mut stack: Vec = roots.iter().rev().copied().collect(); + while let Some(id) = stack.pop() { + canonical.push(id); + if let Some(kids) = children.get(&id) { + stack.extend(kids.iter().rev()); + } + } + canonical +} + +/// Reorder `sheet.cells` into the canonical linearization. Returns whether +/// anything moved. +pub fn canonicalize_sheet(sheet: &mut Sheet) -> bool { + let canonical = canonical_linearization(&sheet.cells); + let current: Vec = sheet.cells.iter().map(|cell| cell.id).collect(); + if canonical == current { + return false; + } + let mut by_id: HashMap = sheet.cells.drain(..).map(|cell| (cell.id, cell)).collect(); + sheet.cells = canonical + .into_iter() + .filter_map(|id| by_id.remove(&id)) + .collect(); + true +} + +/// Read-only preview of a subtree move: the sheet with the drag already +/// applied, or `None` if the move is invalid (self/descendant drop, a +/// descendant outside the columns, or a landing the committed move would +/// reject). Safe every drag frame — zero gate traffic. +#[must_use] +pub fn preview_move_cell_subtree(board: &FlowBoardProjection, sheet_id: SheetId, cell_id: CellId, intent: FlowDropIntent) -> Option { + let column_ids = sheet_column_ids(board, sheet_id).ok()?; + let mut sheet = board.sheet(sheet_id)?.clone(); + apply_move_subtree(&mut sheet, &column_ids, cell_id, intent).ok()?; + if !sheet_topology_ok(&sheet, &column_ids) { + return None; + } + // ONE law with the commit: the committed order is the canonical + // linearization of the moved sheet, so the preview shows exactly it. + canonicalize_sheet(&mut sheet); + Some(sheet) +} + +/// Read-only preview with the dragged subtree lifted out (drag in flight, no +/// valid target hovered). +#[must_use] +pub fn preview_without_subtree(board: &FlowBoardProjection, sheet_id: SheetId, cell_id: CellId) -> Option { + let sheet = board.sheet(sheet_id)?; + let subtree: HashSet = subtree_cell_ids(sheet, cell_id).into_iter().collect(); + if subtree.is_empty() { + return None; + } + let mut sheet = sheet.clone(); + sheet.cells.retain(|cell| !subtree.contains(&cell.id)); + Some(sheet) +} diff --git a/crates/flowstate-flow/src/document.rs b/crates/flowstate-flow/src/document.rs deleted file mode 100644 index 2d243587..00000000 --- a/crates/flowstate-flow/src/document.rs +++ /dev/null @@ -1,274 +0,0 @@ -use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -pub const ROOT_ID: &str = "root"; - -pub type NodeId = String; -pub type Nodes = FxHashMap; - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct Node { - pub value: NodeValue, - pub level: i32, - pub parent: Option, - #[serde(default)] - pub children: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(tag = "tag")] -pub enum NodeValue { - #[serde(rename = "root")] - Root, - #[serde(rename = "flow")] - Flow(Flow), - #[serde(rename = "box")] - Box(BoxNode), -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct Flow { - #[serde(default)] - pub content: String, - #[serde(default)] - pub invert: bool, - #[serde(default)] - pub columns: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct BoxNode { - #[serde(default)] - pub content: String, - #[serde(rename = "flowId")] - pub flow_id: NodeId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub placeholder: Option, - #[serde(default, skip_serializing_if = "is_false")] - pub empty: bool, - #[serde(default, skip_serializing_if = "is_false")] - pub crossed: bool, - #[serde(default, skip_serializing_if = "is_false")] - pub bold: bool, - #[serde(rename = "isExtension", default, skip_serializing_if = "is_false")] - pub is_extension: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FlowDocument { - pub nodes: Nodes, -} - -#[hotpath::measure_all] -impl Default for FlowDocument { - fn default() -> Self { - Self::new() - } -} - -#[hotpath::measure_all] -impl FlowDocument { - #[must_use] - pub fn new() -> Self { - let mut nodes = Nodes::default(); - nodes.insert( - ROOT_ID.to_owned(), - Node { - value: NodeValue::Root, - level: -1, - parent: None, - children: Vec::new(), - }, - ); - Self { nodes } - } - - #[must_use] - pub fn from_nodes(mut nodes: Nodes) -> Self { - nodes.entry(ROOT_ID.to_owned()).or_insert_with(|| Node { - value: NodeValue::Root, - level: -1, - parent: None, - children: Vec::new(), - }); - Self { nodes } - } - - #[must_use] - pub fn root(&self) -> &Node { - self - .nodes - .get(ROOT_ID) - .expect("FlowDocument invariant violated: root node missing") - } - - pub fn root_mut(&mut self) -> &mut Node { - self - .nodes - .get_mut(ROOT_ID) - .expect("FlowDocument invariant violated: root node missing") - } - - pub fn node(&self, id: impl AsRef) -> Option<&Node> { - self.nodes.get(id.as_ref()) - } - - pub fn node_mut(&mut self, id: impl AsRef) -> Option<&mut Node> { - self.nodes.get_mut(id.as_ref()) - } - - pub fn flow(&self, id: impl AsRef) -> Option<&Flow> { - match &self.node(id)?.value { - NodeValue::Flow(flow) => Some(flow), - _ => None, - } - } - - pub fn flow_mut(&mut self, id: impl AsRef) -> Option<&mut Flow> { - match &mut self.node_mut(id)?.value { - NodeValue::Flow(flow) => Some(flow), - _ => None, - } - } - - pub fn box_node(&self, id: impl AsRef) -> Option<&BoxNode> { - match &self.node(id)?.value { - NodeValue::Box(box_node) => Some(box_node), - _ => None, - } - } - - pub fn box_node_mut(&mut self, id: impl AsRef) -> Option<&mut BoxNode> { - match &mut self.node_mut(id)?.value { - NodeValue::Box(box_node) => Some(box_node), - _ => None, - } - } - - #[must_use] - pub fn flow_ids(&self) -> &[NodeId] { - &self.root().children - } - - pub fn parent_flow_id(&self, id: impl AsRef) -> Option { - let id = id.as_ref(); - match &self.node(id)?.value { - NodeValue::Flow(_) => Some(id.to_owned()), - NodeValue::Box(box_node) => Some(box_node.flow_id.clone()), - NodeValue::Root => None, - } - } - - pub fn check_box_id(&self, id: impl AsRef) -> Option { - let id = id.as_ref(); - self.box_node(id).map(|_| id.to_owned()) - } - - pub fn child_index(&self, parent_id: impl AsRef, child_id: impl AsRef) -> Option { - self - .node(parent_id)? - .children - .iter() - .position(|id| id == child_id.as_ref()) - } - - pub fn adjacent_box(&self, id: impl AsRef, direction: Direction) -> Option { - let id = id.as_ref(); - let node = self.node(id)?; - if !matches!(node.value, NodeValue::Box(_)) { - return None; - } - let parent_id = node.parent.as_ref()?; - let parent = self.node(parent_id)?; - let index = parent.children.iter().position(|child| child == id)?; - let next_index = match direction { - Direction::Up => index.checked_sub(1), - Direction::Down => Some(index + 1).filter(|ix| *ix < parent.children.len()), - }; - if let Some(next_index) = next_index { - return parent.children.get(next_index).cloned(); - } - - let parent_box_id = self.check_box_id(parent_id)?; - let mut adjacent_parent = self.adjacent_box(parent_box_id, direction)?; - loop { - let adjacent_node = self.node(&adjacent_parent)?; - if !adjacent_node.children.is_empty() { - let child_index = match direction { - Direction::Up => adjacent_node.children.len() - 1, - Direction::Down => 0, - }; - return adjacent_node.children.get(child_index).cloned(); - } - adjacent_parent = self.adjacent_box(adjacent_parent, direction)?; - } - } - - #[must_use] - pub fn is_worth_saving(&self, ignore_first_empty_flow: bool) -> bool { - let root_children = &self.root().children; - if root_children.is_empty() { - return false; - } - if root_children.len() != 1 || !ignore_first_empty_flow { - return true; - } - - let Some(flow) = self.node(&root_children[0]) else { - return false; - }; - let NodeValue::Flow(flow_value) = &flow.value else { - return false; - }; - if !flow_value.content.is_empty() { - return true; - } - if flow.children.is_empty() { - return false; - } - if flow.children.len() > 1 { - return true; - } - let Some(first_child) = self.node(&flow.children[0]) else { - return false; - }; - match &first_child.value { - NodeValue::Box(box_node) => !(box_node.content.is_empty() && first_child.children.is_empty()), - _ => true, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Direction { - Up, - Down, -} - -#[hotpath::measure] -pub fn new_node_id() -> NodeId { - Uuid::new_v4().to_string() -} - -#[hotpath::measure] -#[must_use] -pub fn new_box_id() -> NodeId { - new_node_id() -} - -#[hotpath::measure] -#[must_use] -pub fn new_flow_id() -> NodeId { - new_node_id() -} - -#[hotpath::measure] -#[must_use] -pub fn constrain_index(index: usize, len: usize) -> usize { - index.min(len) -} - -const fn is_false(value: &bool) -> bool { - !*value -} diff --git a/crates/flowstate-flow/src/format.rs b/crates/flowstate-flow/src/format.rs new file mode 100644 index 00000000..6feedc1e --- /dev/null +++ b/crates/flowstate-flow/src/format.rs @@ -0,0 +1,129 @@ +//! The immutable flow FORMAT vocabulary (.fl0 v2 spec, Part A) plus the shared +//! id aliases and annotation data types. The format is written once into +//! `flow.meta` as a postcard blob and never mutated afterwards — every peer +//! reads the identical column/sheet-type topology, so the normalization law can +//! treat it as a constant. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub type FormatId = Uuid; +pub type SheetTypeId = Uuid; +pub type SheetId = Uuid; +pub type ColumnId = Uuid; +pub type CellId = Uuid; +pub type StrokeId = Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AnnotationOriginator(pub String); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ArgumentSide { + One, + Two, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ColumnDefinition { + pub id: ColumnId, + pub label: String, + pub side: ArgumentSide, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SheetTypeDefinition { + pub id: SheetTypeId, + pub name: String, + pub columns: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowFormat { + pub id: FormatId, + pub name: String, + pub sheet_types: Vec, +} + +impl FlowFormat { + pub fn policy_debate() -> Self { + let affirmative = sheet_type( + "Affirmative", + &[ + ("1AC", ArgumentSide::One), + ("1NC", ArgumentSide::Two), + ("2AC", ArgumentSide::One), + ("Block", ArgumentSide::Two), + ("1AR", ArgumentSide::One), + ("2NR", ArgumentSide::Two), + ("2AR", ArgumentSide::One), + ], + ); + let negative = sheet_type( + "Negative", + &[ + ("1NC", ArgumentSide::Two), + ("2AC", ArgumentSide::One), + ("Block", ArgumentSide::Two), + ("1AR", ArgumentSide::One), + ("2NR", ArgumentSide::Two), + ("2AR", ArgumentSide::One), + ], + ); + Self { + id: Uuid::new_v4(), + name: "Policy Debate".into(), + sheet_types: vec![affirmative, negative], + } + } + + pub fn sheet_type(&self, id: SheetTypeId) -> Option<&SheetTypeDefinition> { + self + .sheet_types + .iter() + .find(|definition| definition.id == id) + } +} + +fn sheet_type(name: &str, columns: &[(&str, ArgumentSide)]) -> SheetTypeDefinition { + SheetTypeDefinition { + id: Uuid::new_v4(), + name: name.into(), + columns: columns + .iter() + .map(|(label, side)| ColumnDefinition { + id: Uuid::new_v4(), + label: (*label).into(), + side: *side, + }) + .collect(), + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BoardPoint { + pub x: f32, + pub y: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BoardRect { + pub min: BoardPoint, + pub max: BoardPoint, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokeStyle { + pub color_rgba: u32, + pub width: f32, + pub opacity: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnnotationStroke { + pub id: StrokeId, + pub sheet_id: SheetId, + pub originator: AnnotationOriginator, + pub points: Vec, + pub style: StrokeStyle, + pub bbox: BoardRect, +} diff --git a/crates/flowstate-flow/src/history.rs b/crates/flowstate-flow/src/history.rs deleted file mode 100644 index 64cbf884..00000000 --- a/crates/flowstate-flow/src/history.rs +++ /dev/null @@ -1,131 +0,0 @@ -use rustc_hash::FxHashMap; - -use crate::actions::ActionBundle; -use crate::document::{FlowDocument, NodeId, ROOT_ID}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HistoryAction { - pub before_focus: Option, - pub after_focus: Option, - pub action_bundle: ActionBundle, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct History { - index: Option, - actions: Vec, -} - -impl History { - pub fn add(&mut self, action_bundle: ActionBundle, before_focus: Option, after_focus: Option) { - let keep = self.index.map_or(0, |index| index + 1); - self.actions.truncate(keep); - self.actions.push(HistoryAction { - before_focus, - after_focus, - action_bundle, - }); - self.index = Some(self.actions.len() - 1); - } - - pub fn undo(&mut self, document: &mut FlowDocument) -> Option> { - let index = self.index?; - let action = self.actions.get_mut(index)?; - let inverse = document.apply_action_bundle(action.action_bundle.clone()); - action.action_bundle = inverse; - self.index = index.checked_sub(1); - Some(action.before_focus.clone()) - } - - pub fn redo(&mut self, document: &mut FlowDocument) -> Option> { - let next_index = self.index.map_or(0, |index| index + 1); - if next_index >= self.actions.len() { - return None; - } - self.index = Some(next_index); - let action = self.actions.get_mut(next_index)?; - let inverse = document.apply_action_bundle(action.action_bundle.clone()); - action.action_bundle = inverse; - Some( - action - .after_focus - .clone() - .or_else(|| action.before_focus.clone()), - ) - } - - #[must_use] - pub const fn can_undo(&self) -> bool { - self.index.is_some() - } - - #[must_use] - pub fn can_redo(&self) -> bool { - self - .index - .map_or(!self.actions.is_empty(), |index| index + 1 < self.actions.len()) - } - - pub fn clear(&mut self) { - self.index = None; - self.actions.clear(); - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct HistoryHolder { - histories: FxHashMap, - last_added_owner: Option, -} - -#[hotpath::measure_all] -impl HistoryHolder { - #[must_use] - pub fn new() -> Self { - let mut histories = FxHashMap::default(); - histories.insert(ROOT_ID.to_owned(), History::default()); - Self { - histories, - last_added_owner: None, - } - } - - pub fn add(&mut self, owner: NodeId, action_bundle: ActionBundle, before_focus: Option, after_focus: Option) { - self - .histories - .entry(owner.clone()) - .or_default() - .add(action_bundle, before_focus, after_focus); - self.last_added_owner = Some(owner); - } - - pub fn undo(&mut self, owner: impl AsRef, document: &mut FlowDocument) -> Option> { - self.histories.get_mut(owner.as_ref())?.undo(document) - } - - pub fn redo(&mut self, owner: impl AsRef, document: &mut FlowDocument) -> Option> { - self.histories.get_mut(owner.as_ref())?.redo(document) - } - - pub fn can_undo(&self, owner: impl AsRef) -> bool { - self - .histories - .get(owner.as_ref()) - .is_some_and(History::can_undo) - } - - pub fn can_redo(&self, owner: impl AsRef) -> bool { - self - .histories - .get(owner.as_ref()) - .is_some_and(History::can_redo) - } - - pub fn clear(&mut self) { - self.histories.clear(); - self - .histories - .insert(ROOT_ID.to_owned(), History::default()); - self.last_added_owner = None; - } -} diff --git a/crates/flowstate-flow/src/intents.rs b/crates/flowstate-flow/src/intents.rs new file mode 100644 index 00000000..b5572fb8 --- /dev/null +++ b/crates/flowstate-flow/src/intents.rs @@ -0,0 +1,153 @@ +//! The plain-data flow intent vocabulary (.fl0 v2 spec, Part B). +//! +//! Every local mutation of a flow document is one of these intents, applied +//! through the flow's gated write path (`flowstate-collab::flow`). The types +//! are pure data — no Loro handles, no projection references — so the editor +//! can construct them without holding the gate, and the fuzz harnesses can +//! generate them. + +use gpui_flowtext::local_intents::LocalIntent; +use serde::{Deserialize, Serialize}; + +use crate::format::{AnnotationOriginator, AnnotationStroke, CellId, SheetId, SheetTypeId, StrokeId}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RelativePosition { + Before, + After, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum FlowDropIntent { + BeforeSibling(CellId), + AfterSibling(CellId), + FirstChildOf(CellId), + LastChildOf(CellId), + RootInColumn { column_index: usize, insertion_index: usize }, +} + +/// Where a NEW cell lands, replacing the former `add_plain_cell` / +/// `add_orphan_at_column_top` / `add_sibling` / `add_response` / +/// `add_first_response` quintet with one resolved-at-commit placement. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CellPlacement { + /// Root cell appended at the end of the sheet order. + ColumnEnd { column_index: usize }, + /// Root cell at the very top of the sheet order (`add_orphan_at_column_top`). + ColumnTop { column_index: usize }, + /// Immediately before/after an existing cell, sharing its column and parent. + Sibling { of: CellId, position: RelativePosition }, + /// First child of `parent` (before its existing child subtrees). + FirstResponseTo { parent: CellId }, + /// Last child of `parent` (after every existing descendant). + ResponseTo { parent: CellId }, +} + +/// Initial rich-text content for a new cell. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum CellSeed { + /// The canonical empty tag-paragraph seed (`seed_cell_flow`). + #[default] + Empty, + /// Full paragraph content (paste of a card, import). + Paragraphs(Vec), +} + +/// One local flow intent = one gate hold = one Loro commit = one undo member. +#[derive(Clone, Debug)] +pub enum FlowIntent { + CreateSheet { + sheet_id: SheetId, + name: String, + sheet_type_id: SheetTypeId, + }, + RenameSheet { + sheet_id: SheetId, + name: String, + }, + DeleteSheet { + sheet_id: SheetId, + }, + MoveSheet { + sheet_id: SheetId, + target_index: usize, + }, + AddCell { + sheet_id: SheetId, + cell_id: CellId, + placement: CellPlacement, + seed: CellSeed, + }, + DeleteCell { + sheet_id: SheetId, + cell_id: CellId, + }, + MoveCellSubtree { + sheet_id: SheetId, + cell_id: CellId, + drop: FlowDropIntent, + }, + /// Toggle-free strike state: `struck` is the desired end state, expressed as + /// a whole-text `MARK_STRIKETHROUGH` mark so concurrent typing merges under + /// it char-level. + SetCellStruck { + sheet_id: SheetId, + cell_id: CellId, + struck: bool, + }, + /// Restyle the first paragraph to the editable tag style when the cell's + /// content has no summary-projection row yet (former + /// `ensure_cell_editable_projection`). + EnsureCellEditable { + sheet_id: SheetId, + cell_id: CellId, + }, + ReplaceCellContent { + sheet_id: SheetId, + cell_id: CellId, + paragraphs: Vec, + }, + AddAnnotation { + sheet_id: SheetId, + stroke: AnnotationStroke, + }, + DeleteAnnotation { + sheet_id: SheetId, + stroke_id: StrokeId, + originator: AnnotationOriginator, + }, + ClearAnnotations { + /// `None` clears the originator's strokes on every sheet. + sheet_id: Option, + originator: AnnotationOriginator, + }, + /// A rich-text intent scoped to one cell's flow, translated from the cell's + /// `RichTextEditor` by its `FlowCellAuthority`. + CellText { + cell_id: CellId, + intent: LocalIntent, + }, +} + +impl FlowIntent { + /// Commit-message class (field forensics, mirrors `LocalIntent::class`). + #[must_use] + pub fn class(&self) -> &'static str { + match self { + Self::CreateSheet { .. } => "flow-create-sheet", + Self::RenameSheet { .. } => "flow-rename-sheet", + Self::DeleteSheet { .. } => "flow-delete-sheet", + Self::MoveSheet { .. } => "flow-move-sheet", + Self::AddCell { .. } => "flow-add-cell", + Self::DeleteCell { .. } => "flow-delete-cell", + Self::MoveCellSubtree { .. } => "flow-move-cell-subtree", + Self::SetCellStruck { .. } => "flow-set-cell-struck", + Self::EnsureCellEditable { .. } => "flow-ensure-cell-editable", + Self::ReplaceCellContent { .. } => "flow-replace-cell-content", + Self::AddAnnotation { .. } => "flow-add-annotation", + Self::DeleteAnnotation { .. } => "flow-delete-annotation", + Self::ClearAnnotations { .. } => "flow-clear-annotations", + Self::CellText { .. } => "flow-cell-text", + } + } +} diff --git a/crates/flowstate-flow/src/lib.rs b/crates/flowstate-flow/src/lib.rs index 3c065dca..e49292cb 100644 --- a/crates/flowstate-flow/src/lib.rs +++ b/crates/flowstate-flow/src/lib.rs @@ -1,25 +1,25 @@ -//! Native Flowstate model for competitive-debate flow documents. -//! -//! This crate ports the core data and mutation model from `debate-flow` into -//! Rust. It intentionally contains no GPUI code so `.fl0` documents can be -//! loaded, saved, tested, and transformed independently from the desktop UI. +//! The .fl0 flow FORMAT crate (CRDT-first, spec Part A) — what +//! `flowstate-document` is to `.db8`: the Loro container schema, the total +//! board materializer + normalization law, the plain-data intent vocabulary, +//! the pure board operations behind drag preview AND commit, and the FLOWFL0 +//! v2 container. The gated write path lives in `flowstate-collab::flow`. -mod actions; -mod document; -mod history; +pub mod board_ops; +pub mod format; +pub mod intents; +pub mod loro_projection; +pub mod loro_schema; mod persistence; -mod styles; +pub mod projection; -pub use actions::{ - Action, ActionBundle, CommandResult, FormatKind, add_new_box_actions, add_new_empty_actions, add_new_extension_actions, add_new_flow_actions, - delete_node_actions, move_node_actions, new_update_action, toggle_box_format_actions, -}; -pub use document::{BoxNode, Flow, FlowDocument, Node, NodeId, NodeValue, Nodes, ROOT_ID, constrain_index, new_box_id, new_flow_id}; -pub use history::{History, HistoryAction, HistoryHolder}; -pub use persistence::{ - CURRENT_SAVE_VERSION, SaveableFlowDocument, get_json, load_flow_document, load_flow_document_or_new, load_nodes, save_flow_document, -}; -pub use styles::{ - DebateStyle, DebateStyleFlow, DebateStyleKey, DebateStyleTemplate, TimerSpeech, all_debate_style_templates, debate_style, debate_style_label, - debate_style_templates, +pub use format::{ + AnnotationOriginator, AnnotationStroke, ArgumentSide, BoardPoint, BoardRect, CellId, ColumnDefinition, ColumnId, FlowFormat, FormatId, + SheetId, SheetTypeDefinition, SheetTypeId, StrokeId, StrokeStyle, }; +pub use intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent, RelativePosition}; +pub use loro::VersionVector; +pub use loro_projection::FlowDefect; +pub use persistence::{FL0_VERSION, decode_fl0_snapshot, encode_fl0_snapshot, read_fl0, write_fl0}; +pub use projection::{Cell, CellSummary, FlowBoardProjection, Sheet}; + +pub const FLOW_EXTENSION: &str = "fl0"; diff --git a/crates/flowstate-flow/src/loro_projection.rs b/crates/flowstate-flow/src/loro_projection.rs new file mode 100644 index 00000000..d8b24cb8 --- /dev/null +++ b/crates/flowstate-flow/src/loro_projection.rs @@ -0,0 +1,507 @@ +//! The TOTAL board materializer + normalization law (.fl0 v2 spec, Part A). +//! +//! Merged LWW states can violate flow invariants (concurrent moves, deletes +//! racing adds, parent rewrites crossing sheet deletes). The materializer +//! never fails on such states — it applies deterministic normalization (a pure +//! function of canonical state, so every peer converges on the identical +//! board) and reports [`FlowDefect`]s in the .db8 quarantine philosophy. The +//! import path never writes repairs; a capped `"repair"`-origin local +//! canonicalization pass (the runtime) may later rewrite canonical state to +//! match, converging with what every peer already renders. +//! +//! Normalization order: +//! 1. liveness (in map ∧ in order; repair by deterministic append / skip), +//! 2. unknown column → clamp to the first column, +//! 3. dangling parent → orphan, +//! 4. parent-cycle break (null the greatest-uuid member), +//! 5. column-adjacency violation → orphan, +//! 6. sibling-run contiguity → canonical DFS re-linearization. + +use std::sync::Arc; + +use flowstate_document::{PARAGRAPH_ANALYTIC, PARAGRAPH_TAG, PARAGRAPH_UNDERTAG, RegionRows, SEMANTIC_CITE, materialize_single_flow}; +use gpui_flowtext::{DocumentProjection, DocumentTheme, InputBlock, document_from_input_blocks}; +use loro::{LoroDoc, LoroMap}; +use rustc_hash::{FxHashMap, FxHashSet}; +use uuid::Uuid; + +use crate::format::{CellId, SheetId}; +use crate::loro_schema::{ + cell_flow_label, cell_flow_map, cell_order_ids, cells_by_id, child_map, map_string, map_uuid, read_annotations, read_format, sheet_order_ids, + sheets_by_id, +}; +use crate::projection::{Cell, CellSummary, FlowBoardProjection, Sheet}; + +/// One normalized-away violation of the flow invariants. Every defect is a +/// deterministic function of canonical state; the repair pass keys attempt +/// caps off [`FlowDefect::stable_key`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FlowDefect { + /// Sheet-order entry without a live sheet record → skipped. + SheetOrderMissingRecord { key: String }, + /// Sheet record missing from the board order → appended (uuid-sorted). + SheetRecordMissingOrder { sheet: SheetId }, + /// Sheet record whose sheet type is not in the immutable format → its cells + /// are not materialized (nothing can place them in columns). + UnknownSheetType { sheet: SheetId }, + /// Cell-order entry without a live cell record in that sheet → skipped. + CellOrderMissingRecord { sheet: SheetId, key: String }, + /// Cell record missing from its sheet's order → appended (uuid-sorted). + CellRecordMissingOrder { cell: CellId }, + /// Cell record referencing a dead/unknown sheet → not materialized (the + /// sheet delete wins). + CellRecordUnknownSheet { cell: CellId }, + /// Cell record with a missing/unknown column id → clamped to column 0. + UnknownColumn { cell: CellId }, + /// Parent id not live in the same sheet → orphaned. + DanglingParent { cell: CellId }, + /// Parent cycle → the greatest-uuid member orphaned. + ParentCycle { cell: CellId }, + /// Child column is not exactly one right of its parent's → orphaned. + ColumnAdjacency { cell: CellId }, + /// Raw cell order was not the canonical DFS linearization → re-linearized. + SiblingRunSplit { sheet: SheetId }, + /// Cell record without a seeded flow container → empty summary. + CellMissingFlow { cell: CellId }, + /// A defect reported by the shared rich-text materializer for one cell. + CellFlow { + cell: CellId, + defect: flowstate_document::ProjectionDefect, + }, +} + +impl FlowDefect { + #[must_use] + pub fn stable_key(&self) -> String { + match self { + Self::SheetOrderMissingRecord { key } => format!("sheet-order-missing-record:{key}"), + Self::SheetRecordMissingOrder { sheet } => format!("sheet-record-missing-order:{sheet}"), + Self::UnknownSheetType { sheet } => format!("unknown-sheet-type:{sheet}"), + Self::CellOrderMissingRecord { sheet, key } => format!("cell-order-missing-record:{sheet}:{key}"), + Self::CellRecordMissingOrder { cell } => format!("cell-record-missing-order:{cell}"), + Self::CellRecordUnknownSheet { cell } => format!("cell-record-unknown-sheet:{cell}"), + Self::UnknownColumn { cell } => format!("unknown-column:{cell}"), + Self::DanglingParent { cell } => format!("dangling-parent:{cell}"), + Self::ParentCycle { cell } => format!("parent-cycle:{cell}"), + Self::ColumnAdjacency { cell } => format!("column-adjacency:{cell}"), + Self::SiblingRunSplit { sheet } => format!("sibling-run-split:{sheet}"), + Self::CellMissingFlow { cell } => format!("cell-missing-flow:{cell}"), + Self::CellFlow { cell, defect } => format!("cell-flow:{cell}:{}", defect.stable_key()), + } + } +} + +/// Materialize the whole board. Deterministic: byte-equal boards on every peer +/// sharing the same canonical state, regardless of local op history. +#[hotpath::measure] +pub fn materialize_board(doc: &LoroDoc) -> anyhow::Result<(FlowBoardProjection, Vec)> { + let format = read_format(doc)?; + let mut defects = Vec::new(); + + // ---- Sheet linearization (normalization step 1, sheet scope) ------------- + let sheet_registry = sheets_by_id(doc); + let mut live_sheet_keys: Vec = { + let mut keys: Vec = sheet_registry.keys().map(|key| key.to_string()).collect(); + keys.sort(); + keys + }; + let ordered = sheet_order_ids(doc); + let live: FxHashSet<&String> = live_sheet_keys.iter().collect(); + let mut sheet_keys: Vec = Vec::with_capacity(live_sheet_keys.len()); + let mut seen: FxHashSet = FxHashSet::default(); + for key in &ordered { + if !live.contains(key) { + defects.push(FlowDefect::SheetOrderMissingRecord { key: key.clone() }); + continue; + } + if seen.insert(key.clone()) { + sheet_keys.push(key.clone()); + } + } + live_sheet_keys.retain(|key| !seen.contains(key)); + for key in live_sheet_keys { + // Record without an order entry: deterministic uuid-sorted append. + if let Ok(sheet) = Uuid::parse_str(&key) { + defects.push(FlowDefect::SheetRecordMissingOrder { sheet }); + } + sheet_keys.push(key); + } + + // ---- Cells grouped by sheet ---------------------------------------------- + let cell_registry = cells_by_id(doc); + let mut cell_keys: Vec = cell_registry.keys().map(|key| key.to_string()).collect(); + cell_keys.sort(); + struct RawCell { + id: CellId, + sheet_id: SheetId, + column_id: Option, + parent_id: Option, + map: LoroMap, + } + let mut cells_by_sheet: FxHashMap> = FxHashMap::default(); + let live_sheet_ids: FxHashSet = sheet_keys + .iter() + .filter_map(|key| Uuid::parse_str(key).ok()) + .collect(); + for key in cell_keys { + let Some(map) = child_map(&cell_registry, &key) else { + continue; + }; + let Some(id) = map_uuid(&map, "id").filter(|id| id.to_string() == key) else { + continue; + }; + let Some(sheet_id) = map_uuid(&map, "sheet_id") else { + defects.push(FlowDefect::CellRecordUnknownSheet { cell: id }); + continue; + }; + if !live_sheet_ids.contains(&sheet_id) { + defects.push(FlowDefect::CellRecordUnknownSheet { cell: id }); + continue; + } + cells_by_sheet.entry(sheet_id).or_default().push(RawCell { + id, + sheet_id, + column_id: map_uuid(&map, "column_id"), + parent_id: map_uuid(&map, "parent_id"), + map, + }); + } + + // ---- Per-sheet normalization ---------------------------------------------- + let mut sheets = Vec::with_capacity(sheet_keys.len()); + for key in &sheet_keys { + let Some(sheet_map) = child_map(&sheet_registry, key) else { + continue; + }; + let Ok(sheet_id) = Uuid::parse_str(key) else { + continue; + }; + let name = map_string(&sheet_map, "name").unwrap_or_default(); + let Some(sheet_type_id) = map_uuid(&sheet_map, "sheet_type_id") else { + defects.push(FlowDefect::UnknownSheetType { sheet: sheet_id }); + sheets.push(Sheet { + id: sheet_id, + name, + sheet_type_id: Uuid::nil(), + cells: Vec::new(), + annotations: Vec::new(), + }); + continue; + }; + let Some(definition) = format.sheet_type(sheet_type_id) else { + defects.push(FlowDefect::UnknownSheetType { sheet: sheet_id }); + sheets.push(Sheet { + id: sheet_id, + name, + sheet_type_id, + cells: Vec::new(), + annotations: Vec::new(), + }); + continue; + }; + let column_ids: Vec = definition.columns.iter().map(|column| column.id).collect(); + + let mut raw_cells = cells_by_sheet.remove(&sheet_id).unwrap_or_default(); + debug_assert!(raw_cells.iter().all(|cell| cell.sheet_id == sheet_id)); + + // Step 1 (cell liveness): order ∧ map, deterministic appends for + // record-without-order, skips (+defect) for order-without-record. + let by_id: FxHashMap = raw_cells + .iter() + .enumerate() + .map(|(ix, cell)| (cell.id, ix)) + .collect(); + let mut linear: Vec = Vec::with_capacity(raw_cells.len()); + let mut seen: FxHashSet = FxHashSet::default(); + for entry in cell_order_ids(&sheet_map) { + let Some(id) = Uuid::parse_str(&entry) + .ok() + .filter(|id| by_id.contains_key(id)) + else { + defects.push(FlowDefect::CellOrderMissingRecord { sheet: sheet_id, key: entry }); + continue; + }; + if seen.insert(id) { + linear.push(id); + } + } + // raw_cells is uuid-key-sorted already, so the append order is + // deterministic. + for cell in &raw_cells { + if seen.insert(cell.id) { + defects.push(FlowDefect::CellRecordMissingOrder { cell: cell.id }); + linear.push(cell.id); + } + } + + // Step 2 (unknown column → clamp) — requires at least one column; a + // format with an empty column list cannot place any cell. + let level_of = |column: Option| column.and_then(|column| column_ids.iter().position(|candidate| *candidate == column)); + for cell in &mut raw_cells { + if level_of(cell.column_id).is_none() { + defects.push(FlowDefect::UnknownColumn { cell: cell.id }); + cell.column_id = column_ids.first().copied(); + } + } + + // Step 3 (dangling parent → orphan). + for cell in &mut raw_cells { + if let Some(parent) = cell.parent_id + && !by_id.contains_key(&parent) + { + defects.push(FlowDefect::DanglingParent { cell: cell.id }); + cell.parent_id = None; + } + } + + // Step 4 (cycle break: null the greatest-uuid member of each cycle). + loop { + let parent_of: FxHashMap = raw_cells + .iter() + .filter_map(|cell| cell.parent_id.map(|parent| (cell.id, parent))) + .collect(); + let mut broken: Option = None; + let mut resolved: FxHashSet = FxHashSet::default(); + for cell in &raw_cells { + if resolved.contains(&cell.id) { + continue; + } + let mut chain: Vec = Vec::new(); + let mut on_chain: FxHashSet = FxHashSet::default(); + let mut cursor = cell.id; + loop { + if resolved.contains(&cursor) { + break; + } + if !on_chain.insert(cursor) { + // Cycle: members are the chain suffix from the first occurrence. + let start = chain.iter().position(|id| *id == cursor).unwrap_or(0); + let member = chain[start..].iter().copied().max().unwrap_or(cursor); + broken = Some(member); + break; + } + chain.push(cursor); + match parent_of.get(&cursor) { + Some(parent) => cursor = *parent, + None => break, + } + } + if broken.is_some() { + break; + } + resolved.extend(chain); + } + let Some(member) = broken else { + break; + }; + defects.push(FlowDefect::ParentCycle { cell: member }); + if let Some(cell) = raw_cells.iter_mut().find(|cell| cell.id == member) { + cell.parent_id = None; + } + } + + // Step 5 (column adjacency → orphan). Depends only on the cell's own + // column vs its parent's, so a single pass suffices. + let levels: FxHashMap = raw_cells + .iter() + .filter_map(|cell| level_of(cell.column_id).map(|level| (cell.id, level))) + .collect(); + for cell in &mut raw_cells { + let Some(parent) = cell.parent_id else { + continue; + }; + let child_level = levels.get(&cell.id).copied(); + let parent_level = levels.get(&parent).copied(); + let adjacent = matches!((child_level, parent_level), (Some(child), Some(parent)) if child == parent + 1); + if !adjacent { + defects.push(FlowDefect::ColumnAdjacency { cell: cell.id }); + cell.parent_id = None; + } + } + + // Step 6 (canonical DFS re-linearization): roots in linear order, each + // followed by its whole subtree, children in linear order. A well-formed + // order maps to itself; anything else regroups deterministically. + let normalized: FxHashMap = raw_cells.iter().map(|cell| (cell.id, cell)).collect(); + let mut children: FxHashMap> = FxHashMap::default(); + let mut roots: Vec = Vec::new(); + for id in &linear { + match normalized.get(id).and_then(|cell| cell.parent_id) { + Some(parent) => children.entry(parent).or_default().push(*id), + None => roots.push(*id), + } + } + let mut canonical: Vec = Vec::with_capacity(linear.len()); + let mut stack: Vec = roots.iter().rev().copied().collect(); + while let Some(id) = stack.pop() { + canonical.push(id); + if let Some(kids) = children.get(&id) { + stack.extend(kids.iter().rev()); + } + } + debug_assert_eq!(canonical.len(), linear.len(), "DFS must visit every live cell exactly once"); + if canonical != linear { + defects.push(FlowDefect::SiblingRunSplit { sheet: sheet_id }); + } + + // ---- Summaries ----------------------------------------------------------- + let mut cells = Vec::with_capacity(canonical.len()); + for id in canonical { + let raw = normalized[&id]; + let summary = match cell_flow_map(&raw.map) { + Some(flow) => { + let label = cell_flow_label(id); + match materialize_single_flow(doc, &flow, &label) { + Ok(rows) => { + for defect in rows.defects.iter().cloned() { + defects.push(FlowDefect::CellFlow { cell: id, defect }); + } + summary_from_rows(&rows.blocks) + }, + Err(_) => { + defects.push(FlowDefect::CellMissingFlow { cell: id }); + CellSummary::default() + }, + } + }, + None => { + defects.push(FlowDefect::CellMissingFlow { cell: id }); + CellSummary::default() + }, + }; + cells.push(Cell { + id, + column_id: raw.column_id.unwrap_or_else(Uuid::nil), + parent_id: raw.parent_id, + summary, + }); + } + + sheets.push(Sheet { + id: sheet_id, + name, + sheet_type_id, + cells, + annotations: Vec::new(), + }); + } + + // ---- Annotations (unchanged convergent blobs, routed by sheet) ----------- + for stroke in read_annotations(doc) { + if let Some(sheet) = sheets.iter_mut().find(|sheet| sheet.id == stroke.sheet_id) { + sheet.annotations.push(stroke); + } + } + + Ok((FlowBoardProjection { format, sheets }, defects)) +} + +/// Materialize ONE cell's full rich-text projection (editor attach). The board +/// carries only summaries; this is the on-demand deep materialization. +#[hotpath::measure] +pub fn materialize_cell_projection(doc: &LoroDoc, cell_id: CellId, theme: DocumentTheme) -> anyhow::Result { + let rows = materialize_cell_rows(doc, cell_id)?; + Ok(document_from_rows(doc, rows, theme)) +} + +/// The raw materialized rows for one cell (shared by the projection above and +/// the runtime's summary refresh). +#[hotpath::measure] +pub fn materialize_cell_rows(doc: &LoroDoc, cell_id: CellId) -> anyhow::Result { + let cell = crate::loro_schema::cell_map(doc, cell_id).ok_or_else(|| anyhow::anyhow!("unknown cell {cell_id}"))?; + let flow = cell_flow_map(&cell).ok_or_else(|| anyhow::anyhow!("cell {cell_id} has no flow"))?; + Ok(materialize_single_flow(doc, &flow, &cell_flow_label(cell_id))?) +} + +/// Assemble a full per-cell `DocumentProjection` from materialized rows: +/// durable paragraph/block ids installed, frontier stamped. +#[must_use] +pub fn document_from_rows(doc: &LoroDoc, rows: RegionRows, theme: DocumentTheme) -> DocumentProjection { + let RegionRows { + blocks, + paragraph_ids, + block_ids, + defects: _, + } = rows; + let mut document = document_from_input_blocks(theme, blocks); + if paragraph_ids.len() == document.paragraphs.len() { + document.ids.paragraph_ids = Arc::new(paragraph_ids); + } + if block_ids.len() == document.blocks.len() { + document.ids.block_ids = Arc::new(block_ids); + } + document.frontier = doc.state_frontiers().encode(); + document +} + +/// The board-summary law (former `Cell::summary_text` / +/// `uses_summary_projection` / strike detection), over materialized rows. +#[must_use] +pub fn summary_from_rows(blocks: &[InputBlock]) -> CellSummary { + let paragraphs: Vec<&gpui_flowtext::InputParagraph> = blocks + .iter() + .filter_map(|block| match block { + InputBlock::Paragraph(paragraph) => Some(paragraph), + _ => None, + }) + .collect(); + + let mut full_text = String::new(); + let mut summary_rows: Vec = Vec::new(); + let mut uses_summary_projection = false; + let mut any_visible_run = false; + let mut all_struck = true; + + for (ix, paragraph) in paragraphs.iter().enumerate() { + let text: String = paragraph.runs.iter().map(|run| run.text.as_str()).collect(); + if ix > 0 { + full_text.push('\n'); + } + full_text.push_str(&text); + + for run in ¶graph.runs { + if !run.text.is_empty() { + any_visible_run = true; + if !run.styles.strikethrough { + all_struck = false; + } + } + } + + if matches!(paragraph.style, PARAGRAPH_TAG | PARAGRAPH_UNDERTAG | PARAGRAPH_ANALYTIC) { + uses_summary_projection = true; + summary_rows.push(text); + continue; + } + // An empty cite run still flips the summary-projection flag (the former + // `uses_summary_projection` law) — only non-empty cite text becomes a row. + if paragraph + .runs + .iter() + .any(|run| run.styles.semantic == SEMANTIC_CITE) + { + uses_summary_projection = true; + } + let cite_text: String = paragraph + .runs + .iter() + .filter(|run| run.styles.semantic == SEMANTIC_CITE) + .map(|run| run.text.as_str()) + .collect(); + if !cite_text.is_empty() { + summary_rows.push(cite_text); + } + } + + let summary_text: Arc = if summary_rows.is_empty() { + Arc::from(full_text.as_str()) + } else { + Arc::from(summary_rows.join("\n").as_str()) + }; + CellSummary { + summary_text, + uses_summary_projection, + struck: any_visible_run && all_struck, + is_empty: full_text.trim().is_empty(), + } +} diff --git a/crates/flowstate-flow/src/loro_schema.rs b/crates/flowstate-flow/src/loro_schema.rs new file mode 100644 index 00000000..14203c25 --- /dev/null +++ b/crates/flowstate-flow/src/loro_schema.rs @@ -0,0 +1,544 @@ +//! The .fl0 v2 Loro container schema (spec Part A). +//! +//! ```text +//! LoroDoc (.fl0 v2) +//! ├─ "flow.meta" (Map): format (postcard, immutable), schema_version = 2, +//! │ document_id (uuid string), created_at/modified_at +//! ├─ "flow.sheet_order" (MovableList) — board order of sheet uuids +//! ├─ "flow.sheets_by_id" (Map) → {sheet_uuid} (Map): +//! │ id, name (LWW), sheet_type_id, "cell_order" (MovableList) +//! ├─ "flow.cells_by_id" (Map) → {cell_uuid} (Map): +//! │ id, sheet_id, column_id (LWW), parent_id (LWW, absent = root), +//! │ "flow" (Map) — the EXACT .db8 flow shape (`ensure_flow`): sentinel +//! │ text + MARK_* styles + a per-cell "paragraphs_by_id" registry +//! └─ "flow.annotations" (Map): stroke uuid → postcard blob +//! (write-once/delete-only — already proven convergent) +//! ``` +//! +//! Ordering is `MovableList` (`mov` writes only the order list, never a cell map +//! or text container, so a reorder can never clobber a concurrent text edit); +//! the parent tree is per-cell LWW `parent_id`/`column_id`. Everything here +//! writes WITHOUT committing — the caller (the gated flow runtime, or a test) +//! owns the commit and its origin. + +use flowstate_document::loro_schema::{ + FLOW_TEXT_KEY, MARK_PARAGRAPH_STYLE, PARAGRAPHS_BY_ID, ROOT_FIRST_PARAGRAPH_ID, configure_text_styles, ensure_flow, +}; +use gpui_flowtext::{InputParagraph, ParagraphStyle, RunSemanticStyle}; +use loro::{ContainerTrait as _, LoroDoc, LoroMap, LoroMovableList, LoroResult, LoroText, LoroValue, ValueOrContainer, cursor::Side}; +use uuid::Uuid; + +use crate::format::{AnnotationStroke, CellId, FlowFormat, SheetId, StrokeId}; + +pub const FLOW_META: &str = "flow.meta"; +pub const FLOW_SHEET_ORDER: &str = "flow.sheet_order"; +pub const FLOW_SHEETS_BY_ID: &str = "flow.sheets_by_id"; +pub const FLOW_CELLS_BY_ID: &str = "flow.cells_by_id"; +pub const FLOW_ANNOTATIONS: &str = "flow.annotations"; + +pub const META_FORMAT: &str = "format"; +pub const META_SCHEMA_VERSION: &str = "schema_version"; +pub const META_DOCUMENT_ID: &str = "document_id"; + +pub const SHEET_CELL_ORDER: &str = "cell_order"; +pub const CELL_FLOW_KEY: &str = "flow"; +pub const CELL_FLOW_KIND: &str = "flow-cell"; + +/// `.fl0` v2 canonical schema version stamped into `flow.meta`. +pub const FLOW_SCHEMA_VERSION: i64 = 2; + +/// The editable tag paragraph style every fresh cell seeds with +/// (`flowstate_document::PARAGRAPH_TAG`, kept literal here to avoid a gpui dep +/// for one constant — asserted equal in tests). +pub const CELL_SEED_PARAGRAPH_STYLE: ParagraphStyle = ParagraphStyle::Custom(3); + +/// Initialize a fresh v2 flow document: meta (immutable format blob, schema +/// version, document id, timestamps) + the root containers + text-style +/// configuration. Does not commit. +pub fn init_flow_document(doc: &LoroDoc, format: &FlowFormat) -> anyhow::Result { + configure_text_styles(doc); + let meta = doc.get_map(FLOW_META); + meta.insert(META_FORMAT, postcard::to_allocvec(format)?)?; + meta.insert(META_SCHEMA_VERSION, FLOW_SCHEMA_VERSION)?; + let document_id = Uuid::new_v4(); + meta.insert(META_DOCUMENT_ID, document_id.to_string())?; + let now = unix_time_secs(); + meta.insert("created_at", now)?; + meta.insert("modified_at", now)?; + meta.insert("created_by_app_version", env!("CARGO_PKG_VERSION"))?; + Ok(document_id) +} + +/// A snapshot import always re-runs this: style configs are per-`LoroDoc` +/// runtime state, not persisted. +pub fn configure_flow_text_styles(doc: &LoroDoc) { + configure_text_styles(doc); +} + +pub fn read_format(doc: &LoroDoc) -> anyhow::Result { + let meta = doc.get_map(FLOW_META); + let Some(ValueOrContainer::Value(LoroValue::Binary(bytes))) = meta.get(META_FORMAT) else { + anyhow::bail!("flow document has no immutable format definition"); + }; + Ok(postcard::from_bytes(&bytes)?) +} + +pub fn read_schema_version(doc: &LoroDoc) -> Option { + match doc.get_map(FLOW_META).get(META_SCHEMA_VERSION)? { + ValueOrContainer::Value(LoroValue::I64(version)) => Some(version), + _ => None, + } +} + +pub fn read_document_id(doc: &LoroDoc) -> Option { + match doc.get_map(FLOW_META).get(META_DOCUMENT_ID)? { + ValueOrContainer::Value(LoroValue::String(value)) => Uuid::parse_str(&value).ok(), + _ => None, + } +} + +pub fn touch_modified_at(doc: &LoroDoc) -> LoroResult<()> { + doc + .get_map(FLOW_META) + .insert("modified_at", unix_time_secs())?; + Ok(()) +} + +pub fn sheet_order(doc: &LoroDoc) -> LoroMovableList { + doc.get_movable_list(FLOW_SHEET_ORDER) +} + +pub fn sheets_by_id(doc: &LoroDoc) -> LoroMap { + doc.get_map(FLOW_SHEETS_BY_ID) +} + +pub fn cells_by_id(doc: &LoroDoc) -> LoroMap { + doc.get_map(FLOW_CELLS_BY_ID) +} + +pub fn annotations_map(doc: &LoroDoc) -> LoroMap { + doc.get_map(FLOW_ANNOTATIONS) +} + +pub fn sheet_map(doc: &LoroDoc, sheet_id: SheetId) -> Option { + child_map(&sheets_by_id(doc), &sheet_id.to_string()) +} + +pub fn cell_map(doc: &LoroDoc, cell_id: CellId) -> Option { + child_map(&cells_by_id(doc), &cell_id.to_string()) +} + +pub fn cell_flow_map(cell: &LoroMap) -> Option { + child_map(cell, CELL_FLOW_KEY) +} + +pub fn cell_order_list(sheet: &LoroMap) -> LoroResult { + sheet.ensure_mergeable_movable_list(SHEET_CELL_ORDER) +} + +/// Defect-label flow id for a cell's rich text (`materialize_single_flow`). +#[must_use] +pub fn cell_flow_label(cell_id: CellId) -> String { + format!("cell.{cell_id}.flow") +} + +/// READ-ONLY text resolution for a flow map (never creates containers): +/// prefer the stored raw container id, fall back to the `text` child. +pub fn flow_text(doc: &LoroDoc, flow: &LoroMap) -> Option { + if let Some(container_id) = map_string(flow, "text_container_id") + && let Ok(id) = loro::ContainerID::try_from(container_id.as_str()) + && let Some(container) = doc.get_container(id) + && container.is_attached() + && !container.is_deleted() + && let Ok(text) = container.into_text() + { + return Some(text); + } + match flow.get(FLOW_TEXT_KEY)? { + ValueOrContainer::Container(container) => container.into_text().ok(), + ValueOrContainer::Value(_) => None, + } +} + +/// READ-ONLY cell text resolution (`cell → flow → text`). +pub fn cell_text(doc: &LoroDoc, cell_id: CellId) -> Option { + let cell = cell_map(doc, cell_id)?; + let flow = cell_flow_map(&cell)?; + flow_text(doc, &flow) +} + +/// READ-ONLY per-cell paragraph registry resolution. +pub fn cell_paragraph_registry(doc: &LoroDoc, cell_id: CellId) -> Option { + let cell = cell_map(doc, cell_id)?; + let flow = cell_flow_map(&cell)?; + child_map(&flow, PARAGRAPHS_BY_ID) +} + +// ---- Sheet records --------------------------------------------------------- + +/// Create (or converge on) a sheet record and insert it into the board order +/// at `order_index` (clamped). Idempotent per field; two peers creating the +/// same sheet id converge by map LWW. +pub fn write_sheet(doc: &LoroDoc, sheet_id: SheetId, name: &str, sheet_type_id: Uuid, order_index: usize) -> LoroResult { + let sheet = sheets_by_id(doc).ensure_mergeable_map(&sheet_id.to_string())?; + sheet.insert("id", sheet_id.to_string())?; + sheet.insert("name", name)?; + sheet.insert("sheet_type_id", sheet_type_id.to_string())?; + cell_order_list(&sheet)?; + let order = sheet_order(doc); + if order_position(&order, &sheet_id.to_string()).is_none() { + order.insert(order_index.min(order.len()), sheet_id.to_string())?; + } + Ok(sheet) +} + +pub fn rename_sheet(doc: &LoroDoc, sheet_id: SheetId, name: &str) -> anyhow::Result<()> { + let sheet = sheet_map(doc, sheet_id).ok_or_else(|| anyhow::anyhow!("unknown sheet"))?; + sheet.insert("name", name)?; + Ok(()) +} + +/// Remove a sheet: its order entry, its record, its cells (records + order are +/// both dropped with the sheet), and its annotations. +pub fn remove_sheet(doc: &LoroDoc, sheet_id: SheetId) -> anyhow::Result<()> { + let order = sheet_order(doc); + if let Some(position) = order_position(&order, &sheet_id.to_string()) { + order.delete(position, 1)?; + } + let cells = cells_by_id(doc); + let sheet_key = sheet_id.to_string(); + let mut doomed = Vec::new(); + cells.for_each(|key, value| { + if let ValueOrContainer::Container(container) = value + && let Ok(cell) = container.into_map() + && map_string(&cell, "sheet_id").as_deref() == Some(sheet_key.as_str()) + { + doomed.push(key.to_string()); + } + }); + for key in doomed { + cells.delete(&key)?; + } + sheets_by_id(doc).delete(&sheet_key)?; + // Annotations of the sheet: delete-only map, same routing rule as the + // materializer (skip-on-unknown-sheet would also hide them, but deleting + // keeps the map from accumulating garbage). + let annotations = annotations_map(doc); + let mut doomed_strokes = Vec::new(); + annotations.for_each(|key, value| { + if let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value + && let Ok(stroke) = postcard::from_bytes::(&bytes) + && stroke.sheet_id == sheet_id + { + doomed_strokes.push(key.to_string()); + } + }); + for key in doomed_strokes { + annotations.delete(&key)?; + } + Ok(()) +} + +pub fn move_sheet(doc: &LoroDoc, sheet_id: SheetId, target_index: usize) -> anyhow::Result<()> { + let order = sheet_order(doc); + let from = order_position(&order, &sheet_id.to_string()).ok_or_else(|| anyhow::anyhow!("unknown sheet"))?; + let to = target_index.min(order.len().saturating_sub(1)); + if from != to { + order.mov(from, to)?; + } + Ok(()) +} + +// ---- Cell records ---------------------------------------------------------- + +/// Create a cell record (map fields + seeded flow) and insert its id into the +/// sheet's `cell_order` at `order_index` (clamped). The rich text is seeded +/// separately by the caller (`seed_cell_flow` / `cell_flow_from_paragraphs`). +pub fn write_cell( + doc: &LoroDoc, + sheet_id: SheetId, + cell_id: CellId, + column_id: Uuid, + parent_id: Option, + order_index: usize, +) -> anyhow::Result { + let sheet = sheet_map(doc, sheet_id).ok_or_else(|| anyhow::anyhow!("unknown sheet"))?; + let cell = cells_by_id(doc).ensure_mergeable_map(&cell_id.to_string())?; + cell.insert("id", cell_id.to_string())?; + cell.insert("sheet_id", sheet_id.to_string())?; + cell.insert("column_id", column_id.to_string())?; + match parent_id { + Some(parent) => cell.insert("parent_id", parent.to_string())?, + None => { + if cell.get("parent_id").is_some() { + cell.delete("parent_id")?; + } + }, + } + let order = cell_order_list(&sheet)?; + if order_position(&order, &cell_id.to_string()).is_none() { + order.insert(order_index.min(order.len()), cell_id.to_string())?; + } + Ok(cell) +} + +pub fn set_cell_column(cell: &LoroMap, column_id: Uuid) -> LoroResult<()> { + cell.insert("column_id", column_id.to_string())?; + Ok(()) +} + +pub fn set_cell_parent(cell: &LoroMap, parent_id: Option) -> LoroResult<()> { + match parent_id { + Some(parent) => cell.insert("parent_id", parent.to_string())?, + None => { + if cell.get("parent_id").is_some() { + cell.delete("parent_id")?; + } + }, + } + Ok(()) +} + +/// Delete a cell record and its order entry (liveness = in map ∧ in order). +pub fn remove_cell(doc: &LoroDoc, sheet_id: SheetId, cell_id: CellId) -> anyhow::Result<()> { + if let Some(sheet) = sheet_map(doc, sheet_id) { + let order = cell_order_list(&sheet)?; + if let Some(position) = order_position(&order, &cell_id.to_string()) { + order.delete(position, 1)?; + } + } + cells_by_id(doc).delete(&cell_id.to_string())?; + Ok(()) +} + +/// `MovableListHandler::mov` over the sheet's cell order: `from`/`to` are +/// current-list indices (Loro's move semantics — the element lands at `to` +/// as observed AFTER the removal of `from`). +pub fn move_cell_order(sheet: &LoroMap, from: usize, to: usize) -> anyhow::Result<()> { + let order = cell_order_list(sheet)?; + if from != to { + order.mov(from, to)?; + } + Ok(()) +} + +pub fn cell_order_ids(sheet: &LoroMap) -> Vec { + let Ok(order) = cell_order_list(sheet) else { + return Vec::new(); + }; + movable_list_strings(&order) +} + +pub fn sheet_order_ids(doc: &LoroDoc) -> Vec { + movable_list_strings(&sheet_order(doc)) +} + +// ---- Cell rich text -------------------------------------------------------- + +/// Ensure the cell's flow container exists in the EXACT .db8 flow shape +/// (sentinel text container + attrs + `text_container_id` + a per-cell +/// paragraph registry child). +pub fn ensure_cell_flow(cell: &LoroMap) -> LoroResult { + let flow = ensure_flow(cell, CELL_FLOW_KEY, CELL_FLOW_KIND)?; + flow.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + Ok(flow) +} + +/// Canonical fresh-cell seed, mirroring `seed_document_body`: the boundary-0 +/// sentinel carrying the TAG paragraph-style mark plus the first paragraph +/// record (`ROOT_FIRST_PARAGRAPH_ID`) in the cell's own registry. Idempotent. +pub fn seed_cell_flow(cell: &LoroMap) -> LoroResult { + let flow = ensure_cell_flow(cell)?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + if text.len_unicode() == 0 || !text.to_string().starts_with('\n') { + text.insert(0, "\n")?; + text.mark(0..1, MARK_PARAGRAPH_STYLE, paragraph_style_value(CELL_SEED_PARAGRAPH_STYLE))?; + } + let paragraphs = flow.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + write_paragraph_record(¶graphs, &text, ROOT_FIRST_PARAGRAPH_ID, 0)?; + Ok(flow) +} + +/// Replace the cell's whole rich text with `paragraphs` (paste / import / +/// `ReplaceCellContent`): clear text + registry, one contiguous insert, merged +/// paragraph-style + run marks, one paragraph record per row (mirrors the +/// .db8 import plan's write shape at cell scale). +pub fn cell_flow_from_paragraphs(cell: &LoroMap, paragraphs: &[InputParagraph]) -> LoroResult { + let flow = ensure_cell_flow(cell)?; + let text = flow.ensure_mergeable_text(FLOW_TEXT_KEY)?; + let registry = flow.ensure_mergeable_map(PARAGRAPHS_BY_ID)?; + let len = text.len_unicode(); + if len > 0 { + text.delete(0, len)?; + } + registry.clear()?; + + if paragraphs.is_empty() { + // Converge on the canonical empty seed rather than a sentinel-less flow. + text.insert(0, "\n")?; + text.mark(0..1, MARK_PARAGRAPH_STYLE, paragraph_style_value(CELL_SEED_PARAGRAPH_STYLE))?; + write_paragraph_record(®istry, &text, ROOT_FIRST_PARAGRAPH_ID, 0)?; + return Ok(flow); + } + + // ONE contiguous insert for the whole flow text, then explicit mark ranges + // (the .db8 import-plan lesson: per-run inserts fragment text ids). + let mut full = String::new(); + let mut boundaries = Vec::with_capacity(paragraphs.len()); + for paragraph in paragraphs { + boundaries.push(full.chars().count()); + full.push('\n'); + for run in ¶graph.runs { + full.push_str(&run.text); + } + } + text.insert(0, &full)?; + + for (ix, paragraph) in paragraphs.iter().enumerate() { + let boundary = boundaries[ix]; + text.mark(boundary..boundary + 1, MARK_PARAGRAPH_STYLE, paragraph_style_value(paragraph.style))?; + let mut unicode_pos = boundary + 1; + for run in ¶graph.runs { + let run_len = run.text.chars().count(); + if run_len > 0 { + mark_run_styles(&text, unicode_pos..unicode_pos + run_len, &run.styles)?; + } + unicode_pos += run_len; + } + let record_key = if ix == 0 { + ROOT_FIRST_PARAGRAPH_ID.to_string() + } else { + format!("paragraph.{}", Uuid::new_v4().as_u128()) + }; + write_paragraph_record(®istry, &text, &record_key, boundary)?; + } + Ok(flow) +} + +/// One durable paragraph record in a cell's registry: identity + boundary/start +/// cursors (start `Side::Left`, boundary `Side::Right` — the .db8 import shape). +pub fn write_paragraph_record(registry: &LoroMap, text: &LoroText, key: &str, boundary_pos: usize) -> LoroResult { + let record = registry.ensure_mergeable_map(key)?; + record.insert("id", key)?; + record.insert("flow_id", CELL_FLOW_KEY)?; + if let Some(cursor) = text.get_cursor(boundary_pos, Side::Left) { + record.insert("start_cursor", cursor.encode())?; + } + if let Some(cursor) = text.get_cursor(boundary_pos, Side::Right) { + record.insert("boundary_cursor", cursor.encode())?; + } + record.ensure_mergeable_map("attrs")?; + Ok(record) +} + +/// Apply `styles` as explicit marks over `range` (unicode). The inverse of +/// `run_styles_from_attrs`; only non-default fields emit marks. +pub fn mark_run_styles(text: &LoroText, range: std::ops::Range, styles: &gpui_flowtext::RunStyles) -> LoroResult<()> { + use flowstate_document::loro_schema::{ + MARK_DIRECT_UNDERLINE, MARK_HIGHLIGHT_STYLE, MARK_RUN_SEMANTIC_STYLE, MARK_STRIKETHROUGH, MARK_VERT_ALIGN, + }; + if let RunSemanticStyle::Custom(slot) = styles.semantic { + text.mark(range.clone(), MARK_RUN_SEMANTIC_STYLE, i64::from(slot))?; + } + if let Some(gpui_flowtext::HighlightStyle::Custom(slot)) = styles.highlight { + text.mark(range.clone(), MARK_HIGHLIGHT_STYLE, i64::from(slot))?; + } + if styles.direct_underline { + text.mark(range.clone(), MARK_DIRECT_UNDERLINE, true)?; + } + if styles.strikethrough { + text.mark(range.clone(), MARK_STRIKETHROUGH, true)?; + } + if let Some(value) = styles.vert_align.mark_value() { + text.mark(range, MARK_VERT_ALIGN, value)?; + } + Ok(()) +} + +// ---- Annotations ---------------------------------------------------------- + +pub fn put_annotation(doc: &LoroDoc, stroke: &AnnotationStroke) -> anyhow::Result<()> { + annotations_map(doc).insert(&stroke.id.to_string(), postcard::to_allocvec(stroke)?)?; + Ok(()) +} + +pub fn delete_annotation(doc: &LoroDoc, stroke_id: StrokeId) -> LoroResult<()> { + let annotations = annotations_map(doc); + if annotations.get(&stroke_id.to_string()).is_some() { + annotations.delete(&stroke_id.to_string())?; + } + Ok(()) +} + +pub fn read_annotations(doc: &LoroDoc) -> Vec { + let mut strokes = Vec::new(); + annotations_map(doc).for_each(|key, value| { + if let ValueOrContainer::Value(LoroValue::Binary(bytes)) = value + && let Ok(stroke) = postcard::from_bytes::(&bytes) + && key == stroke.id.to_string() + { + strokes.push(stroke); + } + }); + strokes.sort_by_key(|stroke| stroke.id); + strokes +} + +// ---- Shared readers -------------------------------------------------------- + +pub(crate) fn child_map(parent: &LoroMap, key: &str) -> Option { + match parent.get(key)? { + ValueOrContainer::Container(container) => container.into_map().ok(), + ValueOrContainer::Value(_) => None, + } +} + +pub(crate) fn map_string(map: &LoroMap, key: &str) -> Option { + match map.get(key)? { + ValueOrContainer::Value(LoroValue::String(value)) => Some(value.to_string()), + _ => None, + } +} + +pub(crate) fn map_uuid(map: &LoroMap, key: &str) -> Option { + map_string(map, key).and_then(|value| Uuid::parse_str(&value).ok()) +} + +fn movable_list_strings(list: &LoroMovableList) -> Vec { + let mut ids = Vec::with_capacity(list.len()); + list.for_each(|value| { + if let ValueOrContainer::Value(LoroValue::String(id)) = value { + ids.push(id.to_string()); + } + }); + ids +} + +pub(crate) fn order_position(list: &LoroMovableList, id: &str) -> Option { + let mut position = None; + let mut ix = 0usize; + list.for_each(|value| { + if position.is_none() { + if let ValueOrContainer::Value(LoroValue::String(entry)) = value + && entry.as_str() == id + { + position = Some(ix); + } + ix += 1; + } + }); + position +} + +pub(crate) fn paragraph_style_value(style: ParagraphStyle) -> i64 { + match style { + ParagraphStyle::Normal => 0, + ParagraphStyle::Custom(slot) => i64::from(slot) + 1, + } +} + +fn unix_time_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) +} diff --git a/crates/flowstate-flow/src/persistence.rs b/crates/flowstate-flow/src/persistence.rs index 5a5c7c5f..c2c4bc52 100644 --- a/crates/flowstate-flow/src/persistence.rs +++ b/crates/flowstate-flow/src/persistence.rs @@ -1,51 +1,69 @@ -use std::{fs, path::Path}; +use std::fs; +use std::io::{self, Cursor, Read as _}; +use std::path::Path; -use anyhow::{Context as _, Result, bail}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use anyhow::{Context as _, bail}; use uuid::Uuid; -use crate::actions::{Action, new_box_action}; -use crate::document::{BoxNode, Flow, FlowDocument, NodeId, NodeValue, Nodes, ROOT_ID, new_box_id, new_flow_id}; - -pub const CURRENT_SAVE_VERSION: u32 = 1; - +const MAGIC: &[u8; 8] = b"FLOWFL0\0"; +/// The v2 container (spec Part A): same magic/framing, payload is the +/// zstd-compressed Loro snapshot of a v2 schema doc. v1 (the pre-release +/// record-blob format) is explicitly rejected — it never shipped. +pub const FL0_VERSION: u32 = 2; +/// Refuse to read pathological on-disk sizes before allocating for them. const MAX_FL0_BYTES: u64 = 64 * 1024 * 1024; -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SaveableFlowDocument { - pub nodes: Nodes, - pub version: u32, -} - -#[hotpath::measure] -pub fn get_json(document: &FlowDocument) -> Result { - let saveable = SaveableFlowDocument { - nodes: document.nodes.clone(), - version: CURRENT_SAVE_VERSION, - }; - serde_json::to_string(&saveable).context("failed to serialize .fl0 document") +/// Encode a raw Loro snapshot as v2 `.fl0` bytes. +#[must_use] +pub fn encode_fl0_snapshot(snapshot: &[u8]) -> Vec { + let compressed = zstd::stream::encode_all(snapshot, 3).expect("zstd encoding an in-memory buffer cannot fail"); + let mut bytes = Vec::with_capacity(MAGIC.len() + 12 + compressed.len()); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(&FL0_VERSION.to_le_bytes()); + bytes.extend_from_slice(&(compressed.len() as u64).to_le_bytes()); + bytes.extend_from_slice(&compressed); + bytes } -#[hotpath::measure] -pub fn load_nodes(data: Value) -> Result { - let version = data - .get("version") - .and_then(Value::as_u64) - .and_then(|version| u32::try_from(version).ok()) - .unwrap_or(0); - if version == CURRENT_SAVE_VERSION { - let saveable: SaveableFlowDocument = serde_json::from_value(data).context("invalid .fl0 document")?; - return Ok(saveable.nodes); +/// Decode v2 `.fl0` bytes back to the raw Loro snapshot. The caller imports it +/// into a fresh doc and validates `flow.meta/schema_version` (wrong-kind / +/// wrong-schema defense). +pub fn decode_fl0_snapshot(bytes: &[u8]) -> anyhow::Result> { + if bytes.len() as u64 > MAX_FL0_BYTES { + bail!("refusing to decode oversized .fl0 payload ({} bytes)", bytes.len()); + } + let mut cursor = Cursor::new(bytes); + let mut magic = [0; 8]; + cursor.read_exact(&mut magic)?; + if &magic != MAGIC { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 signature").into()); + } + let version = read_u32(&mut cursor)?; + if version == 1 { + // v1 was a pre-release format that never shipped; there is nothing to + // migrate. Loud, specific message instead of a generic version error. + return Err( + io::Error::new( + io::ErrorKind::InvalidData, + "this .fl0 uses the pre-release v1 format, which never shipped and cannot be opened", + ) + .into(), + ); } - if version == 0 { - return upgrade_0_1(data).map(|saveable| saveable.nodes); + if version != FL0_VERSION { + return Err(io::Error::new(io::ErrorKind::InvalidData, format!("unsupported .fl0 version {version}")).into()); } - bail!("unsupported .fl0 save version {version}"); + let payload_len = read_u64(&mut cursor)? as usize; + if payload_len != bytes.len().saturating_sub(cursor.position() as usize) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid .fl0 payload length").into()); + } + let mut compressed = Vec::with_capacity(payload_len); + cursor.read_to_end(&mut compressed)?; + Ok(zstd::stream::decode_all(compressed.as_slice())?) } -#[hotpath::measure] -pub fn load_flow_document(path: impl AsRef) -> Result { +/// Read a v2 `.fl0` file (size-capped) and return the raw Loro snapshot. +pub fn read_fl0(path: impl AsRef) -> anyhow::Result> { let path = path.as_ref(); let metadata = fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; if metadata.len() > MAX_FL0_BYTES { @@ -56,48 +74,19 @@ pub fn load_flow_document(path: impl AsRef) -> Result { MAX_FL0_BYTES ); } - let text = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; - let value: Value = serde_json::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?; - let nodes = load_nodes(value)?; - Ok(FlowDocument::from_nodes(nodes)) + decode_fl0_snapshot(&fs::read(path)?) } -#[hotpath::measure] -pub fn load_flow_document_or_new(path: impl AsRef) -> FlowDocument { - let path = path.as_ref(); - match load_flow_document(path) { - Ok(document) => document, - Err(error) => { - if path.exists() { - let quarantine = path.with_file_name(format!( - "{}.corrupt.{}", - path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("flowstate.fl0"), - Uuid::new_v4() - )); - if let Err(rename_error) = fs::rename(path, &quarantine) { - tracing::warn!(path = %path.display(), quarantine = %quarantine.display(), error = %format_args!("{error:#}"), rename_error = %rename_error, "failed to quarantine existing .fl0 document; using a new empty document"); - } else { - tracing::warn!(path = %path.display(), quarantine = %quarantine.display(), error = %format_args!("{error:#}"), "failed to load existing .fl0 document; quarantined it and using a new empty document"); - } - } - FlowDocument::new() - }, - } -} - -#[hotpath::measure] -pub fn save_flow_document(path: impl AsRef, document: &FlowDocument) -> Result<()> { +/// Atomically write a raw Loro snapshot as a v2 `.fl0` file +/// (write-then-rename, same law as the v1 saver). +pub fn write_fl0(path: impl AsRef, snapshot: &[u8]) -> anyhow::Result<()> { let path = path.as_ref(); if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) { - fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + fs::create_dir_all(parent)?; } - let text = get_json(document)?; let temp_path = path.with_file_name(format!( ".{}.{}.tmp", path @@ -106,115 +95,21 @@ pub fn save_flow_document(path: impl AsRef, document: &FlowDocument) -> Re .unwrap_or("flowstate"), Uuid::new_v4() )); - fs::write(&temp_path, text).with_context(|| format!("failed to write temporary {}", temp_path.display()))?; + fs::write(&temp_path, encode_fl0_snapshot(snapshot)).with_context(|| format!("failed to write temporary {}", temp_path.display()))?; fs::rename(&temp_path, path).with_context(|| format!("failed to atomically replace {} with {}", path.display(), temp_path.display()))?; Ok(()) } -#[derive(Clone, Debug, Deserialize)] -struct OldFlow { - #[serde(default)] - content: String, - #[serde(default)] - columns: Vec, - #[serde(default)] - invert: bool, - #[serde(default)] - children: Vec, +fn read_u32(cursor: &mut Cursor<&[u8]>) -> io::Result { + let mut bytes = [0; 4]; + cursor.read_exact(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) } -#[derive(Clone, Debug, Deserialize)] -struct OldBox { - #[serde(default)] - content: String, - #[serde(default)] - children: Vec, - #[serde(default)] - empty: bool, - #[serde(default)] - placeholder: Option, - #[serde(default)] - crossed: bool, -} - -#[hotpath::measure] -fn upgrade_0_1(saved: Value) -> Result { - #[derive(Debug)] - enum LegacyNode { - Flow(OldFlow), - Box(OldBox), - } - - let old_flows: Vec = serde_json::from_value(saved).context("invalid legacy debate-flow document")?; - let mut document = FlowDocument::new(); - let mut pending = Vec::new(); - for (index, old_flow) in old_flows.into_iter().enumerate().rev() { - pending.push((ROOT_ID.to_owned(), index, LegacyNode::Flow(old_flow))); - } - - let mut processed_nodes = 0usize; - const MAX_LEGACY_NODES: usize = 100_000; - while let Some((parent_id, index, node)) = pending.pop() { - processed_nodes += 1; - if processed_nodes > MAX_LEGACY_NODES { - bail!("legacy .fl0 document is too deeply nested or large to upgrade safely"); - } - - match node { - LegacyNode::Flow(old_flow) => { - let flow_id = new_flow_id(); - let add_flow = Action::Add { - parent: parent_id, - id: flow_id.clone(), - index, - value: NodeValue::Flow(Flow { - content: old_flow.content, - invert: old_flow.invert, - columns: old_flow.columns, - }), - }; - document.apply_action(add_flow); - for (box_index, child) in old_flow.children.into_iter().enumerate().rev() { - pending.push((flow_id.clone(), box_index, LegacyNode::Box(child))); - } - }, - LegacyNode::Box(old_box) => { - let id = new_box_id(); - let flow_id = document - .parent_flow_id(&parent_id) - .unwrap_or_else(|| parent_id.clone()); - let add = Action::Add { - parent: parent_id, - id: id.clone(), - index, - value: NodeValue::Box(BoxNode { - content: old_box.content, - flow_id: flow_id.clone(), - placeholder: old_box.placeholder, - empty: old_box.empty, - crossed: old_box.crossed, - bold: false, - is_extension: false, - }), - }; - document.apply_action(add); - for (child_index, child) in old_box.children.into_iter().enumerate().rev() { - pending.push((id.clone(), child_index, LegacyNode::Box(child))); - } - }, - } - } - - Ok(SaveableFlowDocument { - nodes: document.nodes, - version: CURRENT_SAVE_VERSION, - }) -} - -#[allow(dead_code, reason = "Legacy save migration helper is retained for older persisted flow documents.")] -#[hotpath::measure] -fn _new_empty_box(parent_id: NodeId, flow_id: NodeId, index: usize) -> Action { - new_box_action(parent_id, flow_id, index, None) +fn read_u64(cursor: &mut Cursor<&[u8]>) -> io::Result { + let mut bytes = [0; 8]; + cursor.read_exact(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) } #[cfg(test)] @@ -241,71 +136,41 @@ mod tests { let dir = test_dir("flowstate-fl0-size"); let path = dir.join("oversized.fl0"); fs::write(&path, vec![b'{'; (MAX_FL0_BYTES as usize) + 1]).unwrap(); - - let error = load_flow_document(&path).unwrap_err(); + let error = read_fl0(&path).map(|_| ()).unwrap_err(); assert!(error.to_string().contains("file too large")); } #[test] - fn quarantines_bad_existing_document_on_fallback() { + fn load_failure_leaves_the_file_untouched() { let dir = test_dir("flowstate-fl0-corrupt"); let path = dir.join("broken.fl0"); - fs::write(&path, b"{not json").unwrap(); + fs::write(&path, b"{not a flow document").unwrap(); + assert!(read_fl0(&path).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"{not a flow document"); + } - let document = load_flow_document_or_new(&path); - assert!(document.flow_ids().is_empty()); - assert!(!path.exists()); - assert!(fs::read_dir(&dir).unwrap().any(|entry| { - entry - .unwrap() - .file_name() - .to_string_lossy() - .starts_with("broken.fl0.corrupt.") - })); + #[test] + fn save_replaces_atomically_without_leaving_temp_files() { + let dir = test_dir("flowstate-fl0-atomic"); + let path = dir.join("doc.fl0"); + write_fl0(&path, b"snapshot bytes").unwrap(); + write_fl0(&path, b"snapshot bytes").unwrap(); + let entries: Vec<_> = fs::read_dir(&dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(entries, vec!["doc.fl0".to_owned()]); + assert_eq!(read_fl0(&path).unwrap(), b"snapshot bytes"); } #[test] - #[hotpath::measure] - fn loads_current_save_shape() { - let document = FlowDocument::new(); - let json = get_json(&document).unwrap(); - let value: Value = serde_json::from_str(&json).unwrap(); - let nodes = load_nodes(value).unwrap(); - assert!(nodes.contains_key(ROOT_ID)); + fn rejects_corruption() { + assert!(decode_fl0_snapshot(b"{}").is_err()); } #[test] - #[hotpath::measure] - fn upgrades_legacy_flow_array() { - let legacy = serde_json::json!([ - { - "content": "aff", - "level": 0, - "columns": ["1AC", "1NC"], - "invert": false, - "focus": false, - "index": 0, - "lastFocus": [], - "children": [ - { - "content": "advantage", - "children": [], - "index": 0, - "level": 1, - "focus": false, - "placeholder": "type here", - "crossed": true - } - ], - "history": { "index": -1, "data": [], "lastFocus": null }, - "id": 1 - } - ]); - let document = FlowDocument::from_nodes(load_nodes(legacy).unwrap()); - let flow_id = document.flow_ids()[0].clone(); - let box_id = document.node(&flow_id).unwrap().children[0].clone(); - assert_eq!(document.flow(&flow_id).unwrap().content, "aff"); - assert_eq!(document.box_node(&box_id).unwrap().content, "advantage"); - assert!(document.box_node(&box_id).unwrap().crossed); + fn round_trips() { + let decoded = decode_fl0_snapshot(&encode_fl0_snapshot(b"loro snapshot")).unwrap(); + assert_eq!(decoded, b"loro snapshot"); } } diff --git a/crates/flowstate-flow/src/projection.rs b/crates/flowstate-flow/src/projection.rs new file mode 100644 index 00000000..c768b59c --- /dev/null +++ b/crates/flowstate-flow/src/projection.rs @@ -0,0 +1,93 @@ +//! The materialized flow BOARD projection (.fl0 v2 spec, Part A). +//! +//! What `DocumentProjection` is to a .db8 body, `FlowBoardProjection` is to a +//! flow board: a plain-data, deterministic materialization of canonical Loro +//! state. Cell rich text is NOT embedded — each cell carries a cached +//! [`CellSummary`] (the board renders summaries; the full per-cell +//! `DocumentProjection` is materialized on demand by the runtime when a cell +//! editor opens). + +use std::sync::Arc; + +use crate::format::{AnnotationStroke, CellId, ColumnId, FlowFormat, SheetId, SheetTypeId}; + +/// Board-visible digest of one cell's rich text, derived from the cell's +/// materialized rows (the former `Cell::summary_text` / +/// `uses_summary_projection` logic, computed once per text change). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CellSummary { + /// Tag/undertag/analytic rows + cite runs in document order, joined by + /// newlines; the full text when no summary rows exist. + pub summary_text: Arc, + /// Whether the cell renders through the summary projection (any summary + /// row or cite run present). + pub uses_summary_projection: bool, + /// Whether every (non-empty) run is struck through. + pub struck: bool, + /// Whether the cell has no visible text at all. + pub is_empty: bool, +} + +impl Default for CellSummary { + fn default() -> Self { + Self { + summary_text: Arc::from(""), + uses_summary_projection: false, + struck: false, + is_empty: true, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub id: CellId, + pub column_id: ColumnId, + pub parent_id: Option, + pub summary: CellSummary, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Sheet { + pub id: SheetId, + pub name: String, + pub sheet_type_id: SheetTypeId, + /// Flat sheet order (the canonical DFS linearization: every subtree + /// contiguous, roots and siblings in order-list order). + pub cells: Vec, + pub annotations: Vec, +} + +impl Sheet { + #[must_use] + pub fn cell(&self, id: CellId) -> Option<&Cell> { + self.cells.iter().find(|cell| cell.id == id) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FlowBoardProjection { + pub format: FlowFormat, + pub sheets: Vec, +} + +impl FlowBoardProjection { + #[must_use] + pub fn sheet(&self, id: SheetId) -> Option<&Sheet> { + self.sheets.iter().find(|sheet| sheet.id == id) + } + + #[must_use] + pub fn sheet_mut(&mut self, id: SheetId) -> Option<&mut Sheet> { + self.sheets.iter_mut().find(|sheet| sheet.id == id) + } + + /// The sheet owning `cell`, plus the cell itself. + #[must_use] + pub fn cell(&self, cell: CellId) -> Option<(&Sheet, &Cell)> { + self + .sheets + .iter() + .find_map(|sheet| sheet.cell(cell).map(|found| (sheet, found))) + } +} diff --git a/crates/flowstate-flow/src/styles.rs b/crates/flowstate-flow/src/styles.rs deleted file mode 100644 index ee2d2057..00000000 --- a/crates/flowstate-flow/src/styles.rs +++ /dev/null @@ -1,395 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DebateStyleKey { - Policy, - PublicForum, - LincolnDouglas, - Congress, - WorldSchools, - BigQuestions, - NofSpar, - Parli, - Classic, - BritishParliamentary, - Ippf, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DebateStyleTemplate { - pub key: DebateStyleKey, - pub label: &'static str, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DebateStyleFlow { - pub name: String, - pub columns: Vec, - pub columns_switch: Option>, - pub invert: bool, - pub starter_boxes: Option>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TimerSpeech { - pub name: String, - pub time_ms: u32, - pub secondary: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct DebateStyle { - pub flows: Vec, - pub alternative_flows_1: Option>, - pub timer_speeches: Vec, - pub prep_time_ms: Option, -} - -#[hotpath::measure] -#[must_use] -pub fn all_debate_style_templates() -> Vec { - vec![ - DebateStyleTemplate { - key: DebateStyleKey::Policy, - label: "Policy", - }, - DebateStyleTemplate { - key: DebateStyleKey::PublicForum, - label: "Public Forum", - }, - DebateStyleTemplate { - key: DebateStyleKey::LincolnDouglas, - label: "Lincoln Douglas", - }, - DebateStyleTemplate { - key: DebateStyleKey::Congress, - label: "Congress", - }, - DebateStyleTemplate { - key: DebateStyleKey::WorldSchools, - label: "World Schools", - }, - DebateStyleTemplate { - key: DebateStyleKey::BigQuestions, - label: "Big Questions", - }, - DebateStyleTemplate { - key: DebateStyleKey::NofSpar, - label: "NOF SPAR", - }, - DebateStyleTemplate { - key: DebateStyleKey::Parli, - label: "Parli", - }, - DebateStyleTemplate { - key: DebateStyleKey::Classic, - label: "Classic", - }, - DebateStyleTemplate { - key: DebateStyleKey::BritishParliamentary, - label: "British Parliamentary", - }, - DebateStyleTemplate { - key: DebateStyleKey::Ippf, - label: "International Public Policy Forum", - }, - ] -} - -#[hotpath::measure] -#[must_use] -pub fn debate_style_label(key: DebateStyleKey) -> &'static str { - all_debate_style_templates() - .into_iter() - .find(|template| template.key == key) - .map_or("Policy", |template| template.label) -} - -#[hotpath::measure] -#[must_use] -pub fn debate_style_templates(key: DebateStyleKey, ld_toc_circuit: bool) -> Vec { - let style = debate_style(key); - if key == DebateStyleKey::LincolnDouglas && ld_toc_circuit { - style.alternative_flows_1.unwrap_or(style.flows) - } else { - style.flows - } -} - -#[hotpath::measure] -#[must_use] -pub fn debate_style(key: DebateStyleKey) -> DebateStyle { - match key { - DebateStyleKey::Policy => DebateStyle { - flows: vec![ - flow("aff", &["1AC", "1NC", "2AC", "2NC/1NR", "1AR", "2NR", "2AR"], false), - flow("neg", &["1NC", "2AC", "2NC/1NR", "1AR", "2NR", "2AR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("1AC", 8 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, false), - ("1NC", 8 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, true), - ("2AC", 8 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, false), - ("2NC", 8 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, true), - ("1NR", 5 * 60 * 1000, true), - ("1AR", 5 * 60 * 1000, false), - ("2NR", 5 * 60 * 1000, true), - ("2AR", 5 * 60 * 1000, false), - ]), - prep_time_ms: Some(8 * 60 * 1000), - }, - DebateStyleKey::PublicForum => DebateStyle { - flows: vec![ - flow_switch( - "aff", - &["AC", "NC", "AR", "NR", "AS", "NS", "AFF", "NFF"], - &["AC", "NR", "AR", "NS", "AS", "NFF", "AFF"], - false, - ), - flow_switch( - "neg", - &["NC", "AR", "NR", "AS", "NS", "AFF", "NFF"], - &["NC", "AC", "NR", "AR", "NS", "AS", "NFF", "AFF"], - true, - ), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("AC", 4 * 60 * 1000, false), - ("NC", 4 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("AR", 4 * 60 * 1000, false), - ("NR", 4 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("AS", 3 * 60 * 1000, false), - ("NS", 3 * 60 * 1000, true), - ("GCX", 3 * 60 * 1000, false), - ("AFF", 2 * 60 * 1000, false), - ("NFF", 2 * 60 * 1000, true), - ]), - prep_time_ms: Some(3 * 60 * 1000), - }, - DebateStyleKey::LincolnDouglas => DebateStyle { - flows: vec![ - flow_starter("aff", &["AC", "NR", "1AR", "2NR", "2AR"], false, &["Value", "Criterion"]), - flow_starter("neg", &["NC", "1AR", "2NR", "2AR"], true, &["Value", "Criterion"]), - ], - alternative_flows_1: Some(vec![ - flow_starter("aff", &["AC", "NR", "1AR", "2NR", "2AR"], false, &["type here"]), - flow_starter("neg", &["NC", "1AR", "2NR", "2AR"], true, &["type here"]), - flow_starter("1ar", &["1AR", "2NR", "2AR"], false, &["type here"]), - flow_starter("2nr", &["2NR", "2AR"], true, &["type here"]), - ]), - timer_speeches: speeches(&[ - ("AC", 6 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, false), - ("NC", 7 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("1AR", 4 * 60 * 1000, false), - ("NR", 6 * 60 * 1000, true), - ("2AR", 3 * 60 * 1000, false), - ]), - prep_time_ms: Some(4 * 60 * 1000), - }, - DebateStyleKey::Congress => DebateStyle { - flows: vec![flow( - "bill", - &[ - "1A", "Q/1N", "Q/2A", "Q/2N", "Q/3A", "Q/3N", "Q/4A", "Q/4N", "Q/5A", "Q/5N", "Q/6A", "Q/6N", "Q/7A", "Q/7N", "Q/8A", "Q/8N", "Q/9A", - "Q/9N", "Q/10A", "Q/10N", "Q/11A", "Q/11N", "Q/12A", "Q/12N", "Q/13A", "Q/13N", "Q/14A", "Q/14N", "Q/15A", "Q/15N", "Q/16A", "Q/16N", - "Q/17A", "Q/17N", "Q/18A", "Q/18N", "Q/19A", "Q/19N", "Q/20A", "Q/20N", "Q/20A", "Q/20N", "Q/21A", "Q/21N", "Q/22A", "Q/22N", "Q/23A", - "Q/23N", "Q/24A", "Q/24N", "Q/25A", "Q/25N", - ], - false, - )], - alternative_flows_1: None, - timer_speeches: speeches(&[("speech", 3 * 60 * 1000, false)]), - prep_time_ms: None, - }, - DebateStyleKey::WorldSchools => DebateStyle { - flows: vec![ - flow("prop", &["P1", "O1", "P2", "O2", "PW", "OW", "OR", "PR"], false), - flow("opp", &["O1", "P2", "O2", "PW", "OW", "OR", "PR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("P1", 8 * 60 * 1000, false), - ("O1", 8 * 60 * 1000, true), - ("P2", 8 * 60 * 1000, false), - ("O2", 8 * 60 * 1000, true), - ("PW", 8 * 60 * 1000, false), - ("OW", 8 * 60 * 1000, true), - ("OR", 4 * 60 * 1000, true), - ("PR", 4 * 60 * 1000, false), - ]), - prep_time_ms: None, - }, - DebateStyleKey::BigQuestions => DebateStyle { - flows: vec![ - flow("aff", &["AC", "NC", "ARb", "NRb", "A3", "N3", "ARt", "NRt"], false), - flow("neg", &["NC", "ARb", "NRb", "A3", "N3", "ARt", "NRt"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("AC", 5 * 60 * 1000, false), - ("NC", 5 * 60 * 1000, true), - ("QS", 3 * 60 * 1000, false), - ("ARb", 4 * 60 * 1000, false), - ("NRb", 4 * 60 * 1000, true), - ("QS", 3 * 60 * 1000, false), - ("A3", 3 * 60 * 1000, false), - ("N3", 3 * 60 * 1000, true), - ("ARt", 3 * 60 * 1000, false), - ("NRt", 3 * 60 * 1000, true), - ]), - prep_time_ms: Some(3 * 60 * 1000), - }, - DebateStyleKey::NofSpar => DebateStyle { - flows: vec![flow("pro", &["PC", "CC", "PR", "CR"], false), flow("con", &["CC", "PR", "CR"], true)], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("PREP", 2 * 60 * 1000, false), - ("PC", 2 * 60 * 1000, false), - ("CC", 2 * 60 * 1000, true), - ("CX", 4 * 60 * 1000, false), - ("PR", 2 * 60 * 1000, false), - ("CR", 2 * 60 * 1000, true), - ]), - prep_time_ms: None, - }, - DebateStyleKey::Parli => DebateStyle { - flows: vec![ - flow("pro", &["1PC", "1OC", "2PC", "2OC/OR", "PR"], false), - flow("opp", &["1OC", "2PC", "2OC/OR", "PR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("1PC", 7 * 60 * 1000, false), - ("1OC", 8 * 60 * 1000, true), - ("2PC", 8 * 60 * 1000, false), - ("2OC", 8 * 60 * 1000, true), - ("OR", 4 * 60 * 1000, true), - ("PR", 5 * 60 * 1000, false), - ]), - prep_time_ms: None, - }, - DebateStyleKey::Classic => DebateStyle { - flows: vec![ - flow("aff", &["AC", "NC/1NR", "1AR", "2NR", "2AR", "NS", "AS"], false), - flow("neg", &["NC/1NR", "1AR", "2NR", "2AR", "AS", "NS"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("AC", 6 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, true), - ("NC", 6 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("1NR", 5 * 60 * 1000, true), - ("CX", 3 * 60 * 1000, false), - ("prep", 2 * 60 * 1000, false), - ("1AR", 7 * 60 * 1000, false), - ("CX", 3 * 60 * 1000, true), - ("prep", 2 * 60 * 1000, true), - ("2NR", 6 * 60 * 1000, true), - ("prep", 2 * 60 * 1000, false), - ("2AR", 4 * 60 * 1000, false), - ("prep", 2 * 60 * 1000, true), - ("NS", 3 * 60 * 1000, true), - ("prep", 2 * 60 * 1000, false), - ("AS", 3 * 60 * 1000, false), - ]), - prep_time_ms: None, - }, - DebateStyleKey::BritishParliamentary => DebateStyle { - flows: vec![ - flow("OG", &["PM", "LO", "DPM", "DLO", "MG", "MO", "GW", "CW"], false), - flow("OO", &["LO", "DPM", "DLO", "MG", "MO", "GW", "CW"], true), - flow("CG", &["MG", "MO", "GW", "CW"], false), - flow("CO", &["MO", "GW", "CW"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("PM", 7 * 60 * 1000, false), - ("LO", 7 * 60 * 1000, true), - ("DPM", 7 * 60 * 1000, false), - ("DLO", 7 * 60 * 1000, true), - ("MG", 7 * 60 * 1000, false), - ("MO", 7 * 60 * 1000, true), - ("GW", 7 * 60 * 1000, false), - ("CW", 7 * 60 * 1000, true), - ]), - prep_time_ms: Some(15 * 1000), - }, - DebateStyleKey::Ippf => DebateStyle { - flows: vec![ - flow("aff", &["A1", "N1", "A2", "N2", "AR", "NR"], false), - flow("neg", &["N1", "A2", "N2", "AR", "NR"], true), - ], - alternative_flows_1: None, - timer_speeches: speeches(&[ - ("A1", 5 * 60 * 1000, false), - ("N1", 5 * 60 * 1000, true), - ("BREAK", 90 * 1000, false), - ("A2", 5 * 60 * 1000, false), - ("N2", 5 * 60 * 1000, true), - ("ACX", 60 * 1000, false), - ("NCX", 60 * 1000, true), - ("AR", 5 * 60 * 1000, false), - ("NR", 5 * 60 * 1000, true), - ]), - prep_time_ms: None, - }, - } -} - -#[hotpath::measure] -fn flow(name: &str, columns: &[&str], invert: bool) -> DebateStyleFlow { - DebateStyleFlow { - name: name.to_owned(), - columns: strings(columns), - columns_switch: None, - invert, - starter_boxes: None, - } -} - -#[hotpath::measure] -fn flow_switch(name: &str, columns: &[&str], columns_switch: &[&str], invert: bool) -> DebateStyleFlow { - DebateStyleFlow { - name: name.to_owned(), - columns: strings(columns), - columns_switch: Some(strings(columns_switch)), - invert, - starter_boxes: None, - } -} - -#[hotpath::measure] -fn flow_starter(name: &str, columns: &[&str], invert: bool, starter_boxes: &[&str]) -> DebateStyleFlow { - DebateStyleFlow { - name: name.to_owned(), - columns: strings(columns), - columns_switch: None, - invert, - starter_boxes: Some(strings(starter_boxes)), - } -} - -#[hotpath::measure] -fn strings(values: &[&str]) -> Vec { - values.iter().map(|value| (*value).to_owned()).collect() -} - -#[hotpath::measure] -fn speeches(values: &[(&str, u32, bool)]) -> Vec { - values - .iter() - .map(|(name, time_ms, secondary)| TimerSpeech { - name: (*name).to_owned(), - time_ms: *time_ms, - secondary: *secondary, - }) - .collect() -} diff --git a/crates/flowstate-flow/tests/v2_board.rs b/crates/flowstate-flow/tests/v2_board.rs new file mode 100644 index 00000000..d4a3b39b --- /dev/null +++ b/crates/flowstate-flow/tests/v2_board.rs @@ -0,0 +1,472 @@ +//! .fl0 v2 schema + materializer gates (build-order step 2): +//! import→materialize identity, normalization determinism under raw +//! concurrent mutation, cycle/orphan/run-split unit laws, and the headline +//! schema property — a reorder writes only the order list and can never +//! clobber a concurrent text edit. + +#[cfg(test)] +mod tests { + use flowstate_flow::format::{CellId, FlowFormat, SheetId}; + use flowstate_flow::loro_projection::{FlowDefect, materialize_board, materialize_cell_rows, summary_from_rows}; + use flowstate_flow::loro_schema::{ + self, cell_flow_from_paragraphs, cell_map, init_flow_document, move_cell_order, read_document_id, read_schema_version, remove_cell, + remove_sheet, seed_cell_flow, sheet_map, write_cell, write_sheet, + }; + use flowstate_flow::{decode_fl0_snapshot, encode_fl0_snapshot}; + use gpui_flowtext::{InputParagraph, InputRun, ParagraphStyle, RunStyles}; + use loro::{ExportMode, LoroDoc}; + use uuid::Uuid; + + fn new_doc(format: &FlowFormat) -> LoroDoc { + let doc = LoroDoc::new(); + init_flow_document(&doc, format).expect("init flow doc"); + doc.commit(); + doc + } + + fn fork(doc: &LoroDoc) -> LoroDoc { + let snapshot = doc.export(ExportMode::Snapshot).expect("export snapshot"); + let forked = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&forked); + forked.import(&snapshot).expect("import snapshot"); + forked + } + + fn exchange(a: &LoroDoc, b: &LoroDoc) { + let for_b = a + .export(ExportMode::updates(&b.oplog_vv())) + .expect("export a→b"); + let for_a = b + .export(ExportMode::updates(&a.oplog_vv())) + .expect("export b→a"); + a.import(&for_a).expect("import into a"); + b.import(&for_b).expect("import into b"); + } + + fn boards_equal(a: &LoroDoc, b: &LoroDoc) { + let (board_a, _) = materialize_board(a).expect("materialize a"); + let (board_b, _) = materialize_board(b).expect("materialize b"); + assert_eq!(board_a, board_b, "peers sharing canonical state must materialize identical boards"); + } + + fn run(text: &str) -> InputRun { + InputRun { + text: text.into(), + styles: RunStyles::default(), + } + } + + fn paragraph(style: ParagraphStyle, runs: Vec) -> InputParagraph { + InputParagraph { style, runs } + } + + struct Board { + doc: LoroDoc, + format: FlowFormat, + sheet: SheetId, + } + + fn board_with_sheet() -> Board { + let format = FlowFormat::policy_debate(); + let doc = new_doc(&format); + let sheet = Uuid::new_v4(); + write_sheet(&doc, sheet, "Case", format.sheet_types[0].id, 0).expect("write sheet"); + doc.commit(); + Board { doc, format, sheet } + } + + impl Board { + fn add_cell(&self, column_index: usize, parent: Option, order_index: usize) -> CellId { + let cell_id = Uuid::new_v4(); + let column = self.format.sheet_types[0].columns[column_index].id; + let cell = write_cell(&self.doc, self.sheet, cell_id, column, parent, order_index).expect("write cell"); + seed_cell_flow(&cell).expect("seed cell flow"); + self.doc.commit(); + cell_id + } + } + + #[test] + fn fresh_board_materializes_and_round_trips_through_snapshot() { + let board = board_with_sheet(); + let root = board.add_cell(0, None, usize::MAX); + let child = board.add_cell(1, Some(root), usize::MAX); + let cell = cell_map(&board.doc, root).expect("cell map"); + cell_flow_from_paragraphs( + &cell, + &[ + paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag line")]), + paragraph(ParagraphStyle::Normal, vec![run("body text")]), + ], + ) + .expect("write cell content"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert_eq!(defects, Vec::new(), "well-formed board materializes without defects"); + assert_eq!(projection.format, board.format); + assert_eq!(projection.sheets.len(), 1); + let sheet = &projection.sheets[0]; + assert_eq!(sheet.name, "Case"); + assert_eq!(sheet.cells.iter().map(|cell| cell.id).collect::>(), vec![root, child]); + assert_eq!(sheet.cells[1].parent_id, Some(root)); + let summary = &sheet.cells[0].summary; + assert_eq!(summary.summary_text.as_ref(), "Tag line"); + assert!(summary.uses_summary_projection); + assert!(!summary.is_empty); + // The fresh child seeded with the canonical empty tag row. + assert!(sheet.cells[1].summary.is_empty); + assert!(sheet.cells[1].summary.uses_summary_projection); + + // Snapshot round trip (the join path): identical board on a fresh doc. + let restored = fork(&board.doc); + assert_eq!(read_schema_version(&restored), Some(2)); + assert_eq!(read_document_id(&restored), read_document_id(&board.doc)); + boards_equal(&board.doc, &restored); + } + + /// THE headline schema property: a reorder writes only the order list, so it + /// can never clobber a concurrent text edit in a moved cell. + #[test] + fn reorder_never_clobbers_a_concurrent_cell_edit() { + let board = board_with_sheet(); + let first = board.add_cell(0, None, usize::MAX); + let second = board.add_cell(0, None, usize::MAX); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + + // Peer A moves `first` after `second` (order-list-only write). + let sheet_a = sheet_map(&peer_a, board.sheet).expect("sheet map"); + move_cell_order(&sheet_a, 0, 1).expect("move"); + peer_a.commit(); + + // Peer B concurrently replaces `first`'s text. + let cell_b = cell_map(&peer_b, first).expect("cell map"); + cell_flow_from_paragraphs(&cell_b, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("surviving edit")])]).expect("edit"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + + let (merged, defects) = materialize_board(&peer_a).expect("materialize merged"); + assert_eq!(defects, Vec::new()); + let sheet = &merged.sheets[0]; + assert_eq!( + sheet.cells.iter().map(|cell| cell.id).collect::>(), + vec![second, first], + "the reorder survived" + ); + assert_eq!( + sheet + .cell(first) + .expect("moved cell") + .summary + .summary_text + .as_ref(), + "surviving edit", + "the concurrent text edit survived the reorder" + ); + } + + #[test] + fn concurrent_char_edits_in_one_cell_merge() { + let board = board_with_sheet(); + let cell_id = board.add_cell(0, None, usize::MAX); + let cell = cell_map(&board.doc, cell_id).expect("cell map"); + cell_flow_from_paragraphs(&cell, &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("shared")])]).expect("seed text"); + board.doc.commit(); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + let text_a = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_a, cell_id).unwrap()) + .unwrap() + .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) + .unwrap(); + let text_b = flowstate_flow::loro_schema::cell_flow_map(&cell_map(&peer_b, cell_id).unwrap()) + .unwrap() + .ensure_mergeable_text(flowstate_document::FLOW_TEXT_KEY) + .unwrap(); + // "\nshared": A prepends at the paragraph head, B appends at the tail. + text_a.insert(1, "A-").expect("insert a"); + peer_a.commit(); + text_b.insert(7, "-B").expect("insert b"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + let rows = materialize_cell_rows(&peer_a, cell_id).expect("cell rows"); + let summary = summary_from_rows(&rows.blocks); + assert_eq!(summary.summary_text.as_ref(), "A-shared-B", "same-cell edits interleave char-level"); + } + + #[test] + fn cycle_break_orphans_the_greatest_uuid_member_deterministically() { + let board = board_with_sheet(); + let a = board.add_cell(0, None, usize::MAX); + let b = board.add_cell(1, Some(a), usize::MAX); + // Raw-write the cycle: a.parent = b (b is already a's child). + let cell_a = cell_map(&board.doc, a).expect("cell a"); + loro_schema::set_cell_parent(&cell_a, Some(b)).expect("cycle write"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + let broken = a.max(b); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::ParentCycle { cell } if *cell == broken)), + "greatest-uuid member reported: {defects:?}" + ); + let sheet = &projection.sheets[0]; + assert_eq!(sheet.cell(broken).expect("broken cell").parent_id, None); + // Determinism: a fresh fork materializes the identical board. + boards_equal(&board.doc, &fork(&board.doc)); + } + + #[test] + fn dangling_parent_is_orphaned() { + let board = board_with_sheet(); + let cell_id = board.add_cell(1, None, usize::MAX); + let cell = cell_map(&board.doc, cell_id).expect("cell map"); + loro_schema::set_cell_parent(&cell, Some(Uuid::new_v4())).expect("dangling write"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::DanglingParent { cell } if *cell == cell_id)) + ); + assert_eq!(projection.sheets[0].cell(cell_id).unwrap().parent_id, None); + } + + #[test] + fn column_adjacency_violation_orphans_the_child() { + let board = board_with_sheet(); + let parent = board.add_cell(0, None, usize::MAX); + // Child two columns right of its parent (adjacency requires exactly one). + let child = board.add_cell(2, Some(parent), usize::MAX); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::ColumnAdjacency { cell } if *cell == child)) + ); + assert_eq!(projection.sheets[0].cell(child).unwrap().parent_id, None); + } + + #[test] + fn split_sibling_run_relinearizes_to_canonical_dfs() { + let board = board_with_sheet(); + let parent = board.add_cell(0, None, usize::MAX); + let first_child = board.add_cell(1, Some(parent), usize::MAX); + let second_child = board.add_cell(1, Some(parent), usize::MAX); + let intruder = board.add_cell(1, None, usize::MAX); + // Raw order surgery: shove the parentless intruder between the two children. + let sheet = sheet_map(&board.doc, board.sheet).expect("sheet map"); + move_cell_order(&sheet, 3, 2).expect("split the run"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::SiblingRunSplit { sheet } if *sheet == board.sheet)) + ); + let cells: Vec = projection.sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect(); + assert_eq!( + cells, + vec![parent, first_child, second_child, intruder], + "canonical DFS keeps the subtree contiguous" + ); + boards_equal(&board.doc, &fork(&board.doc)); + } + + #[test] + fn concurrent_sheet_delete_and_cell_add_converge_on_sheet_delete() { + let board = board_with_sheet(); + board.add_cell(0, None, usize::MAX); + + let peer_a = fork(&board.doc); + let peer_b = fork(&board.doc); + remove_sheet(&peer_a, board.sheet).expect("remove sheet"); + peer_a.commit(); + let orphan = Uuid::new_v4(); + let column = board.format.sheet_types[0].columns[0].id; + let cell = write_cell(&peer_b, board.sheet, orphan, column, None, usize::MAX).expect("concurrent add"); + seed_cell_flow(&cell).expect("seed"); + peer_b.commit(); + + exchange(&peer_a, &peer_b); + boards_equal(&peer_a, &peer_b); + let (projection, defects) = materialize_board(&peer_a).expect("materialize"); + // Loro map LWW: whichever way the sheet-record race resolves, both peers + // agree; with the delete winning the orphan cell is skipped + reported. + if projection.sheets.is_empty() { + assert!( + defects + .iter() + .any(|defect| matches!(defect, FlowDefect::CellRecordUnknownSheet { cell } if *cell == orphan)) + ); + } + } + + #[test] + fn delete_cell_removes_record_and_order() { + let board = board_with_sheet(); + let stays = board.add_cell(0, None, usize::MAX); + let goes = board.add_cell(0, None, usize::MAX); + remove_cell(&board.doc, board.sheet, goes).expect("remove"); + board.doc.commit(); + + let (projection, defects) = materialize_board(&board.doc).expect("materialize"); + assert_eq!(defects, Vec::new(), "a clean delete leaves no liveness defect"); + assert_eq!( + projection.sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect::>(), + vec![stays] + ); + } + + /// Normalization determinism property: forked docs take random RAW mutations + /// (moves, parent rewrites, deletes racing adds — no invariant checked at + /// write time), cross-import, and must materialize byte-equal, defect-equal + /// boards; and materializing twice is a fixpoint. + #[test] + fn normalization_determinism_under_random_concurrent_raw_mutation() { + let mut rng = 0x1234_5678_9abc_def0_u64; + let mut next = move || { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + rng + }; + + for round in 0..60 { + let board = board_with_sheet(); + let mut cells: Vec = Vec::new(); + for _ in 0..6 { + let column = (next() % 3) as usize; + let parent = (column > 0 && next() % 2 == 0 && !cells.is_empty()).then(|| cells[(next() % cells.len() as u64) as usize]); + cells.push(board.add_cell(column, parent, usize::MAX)); + } + + let peers = [fork(&board.doc), fork(&board.doc), fork(&board.doc)]; + for peer in &peers { + for _ in 0..4 { + let choice = next() % 5; + let victim = cells[(next() % cells.len() as u64) as usize]; + match choice { + 0 => { + if let Some(sheet) = sheet_map(peer, board.sheet) { + let len = loro_schema::cell_order_ids(&sheet).len(); + if len > 1 { + let from = (next() % len as u64) as usize; + let to = (next() % len as u64) as usize; + let _ = move_cell_order(&sheet, from, to); + } + } + }, + 1 => { + if let Some(cell) = cell_map(peer, victim) { + let parent = cells[(next() % cells.len() as u64) as usize]; + let _ = loro_schema::set_cell_parent(&cell, Some(parent)); + } + }, + 2 => { + if let Some(cell) = cell_map(peer, victim) { + let column = board.format.sheet_types[0].columns[(next() % 4) as usize].id; + let _ = loro_schema::set_cell_column(&cell, column); + } + }, + 3 => { + let _ = remove_cell(peer, board.sheet, victim); + }, + _ => { + if let Some(cell) = cell_map(peer, victim) { + let _ = cell_flow_from_paragraphs( + &cell, + &[paragraph(flowstate_document::PARAGRAPH_TAG, vec![run(&format!("edit {round}"))])], + ); + } + }, + } + peer.commit(); + } + } + + // Full mesh exchange until quiescent (two rounds suffice for 3 peers). + for _ in 0..2 { + exchange(&peers[0], &peers[1]); + exchange(&peers[1], &peers[2]); + exchange(&peers[0], &peers[2]); + } + + let materialized: Vec<_> = peers + .iter() + .map(|peer| materialize_board(peer).expect("materialize")) + .collect(); + assert_eq!(materialized[0], materialized[1], "round {round}: peers 0/1 diverged"); + assert_eq!(materialized[1], materialized[2], "round {round}: peers 1/2 diverged"); + // Fixpoint: materializing again yields the identical board. + let again = materialize_board(&peers[0]).expect("materialize again"); + assert_eq!(materialized[0].0, again.0, "round {round}: materializer is not a pure function"); + } + } + + /// The old `Cell::summary_text` fixture, verbatim: mixed card + analytic + /// content projects tag/cite/undertag/analytic rows in document order. + #[test] + fn summary_projects_mixed_card_and_analytic_content_in_document_order() { + let cite = |text: &str| { + let mut run = run(text); + run.styles.semantic = flowstate_document::SEMANTIC_CITE; + run + }; + let blocks: Vec = vec![ + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_TAG, vec![run("Tag")])), + gpui_flowtext::InputBlock::Paragraph(paragraph( + ParagraphStyle::Normal, + vec![run("hidden before "), cite("Cite"), run(" hidden after")], + )), + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_UNDERTAG, vec![run("Undertag")])), + gpui_flowtext::InputBlock::Paragraph(paragraph(flowstate_document::PARAGRAPH_ANALYTIC, vec![run("Analytic")])), + ]; + let summary = summary_from_rows(&blocks); + assert_eq!(summary.summary_text.as_ref(), "Tag\nCite\nUndertag\nAnalytic"); + assert!(summary.uses_summary_projection); + assert!(!summary.struck); + assert!(!summary.is_empty); + } + + #[test] + fn fl0_v2_round_trips_and_v1_is_rejected() { + let board = board_with_sheet(); + board.add_cell(0, None, usize::MAX); + let snapshot = board.doc.export(ExportMode::Snapshot).expect("export"); + let encoded = encode_fl0_snapshot(&snapshot); + let decoded = decode_fl0_snapshot(&encoded).expect("decode"); + let restored = LoroDoc::new(); + loro_schema::configure_flow_text_styles(&restored); + restored.import(&decoded).expect("import"); + assert_eq!(read_schema_version(&restored), Some(2)); + boards_equal(&board.doc, &restored); + + // v1 rejection: same magic, version 1. + let mut v1 = Vec::new(); + v1.extend_from_slice(b"FLOWFL0\0"); + v1.extend_from_slice(&1u32.to_le_bytes()); + v1.extend_from_slice(&0u64.to_le_bytes()); + let error = decode_fl0_snapshot(&v1).expect_err("v1 must be rejected"); + assert!(error.to_string().contains("never shipped"), "{error}"); + } +} diff --git a/crates/flowstate-soak/Cargo.toml b/crates/flowstate-soak/Cargo.toml index 23a25a9d..bd39b159 100644 --- a/crates/flowstate-soak/Cargo.toml +++ b/crates/flowstate-soak/Cargo.toml @@ -10,14 +10,31 @@ clap.workspace = true flowstate-collab.workspace = true flowstate-document.workspace = true flowstate-docx.workspace = true +flowstate-flow.workspace = true +gpui-flowtext.workspace = true hotpath.workspace = true loro.workspace = true postcard.workspace = true +uuid.workspace = true [features] -hotpath = ["hotpath/hotpath", "flowstate-collab/hotpath", "flowstate-docx/hotpath", "flowstate-document/hotpath"] -hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "flowstate-collab/hotpath-cpu", "flowstate-docx/hotpath-cpu", "flowstate-document/hotpath-cpu"] -hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "flowstate-collab/hotpath-alloc", "flowstate-docx/hotpath-alloc", "flowstate-document/hotpath-alloc"] +hotpath = ["hotpath/hotpath", "flowstate-collab/hotpath", "flowstate-docx/hotpath", "flowstate-document/hotpath", "flowstate-flow/hotpath"] +hotpath-cpu = [ + "hotpath", + "hotpath/hotpath-cpu", + "flowstate-collab/hotpath-cpu", + "flowstate-docx/hotpath-cpu", + "flowstate-document/hotpath-cpu", + "flowstate-flow/hotpath-cpu", +] +hotpath-alloc = [ + "hotpath", + "hotpath/hotpath-alloc", + "flowstate-collab/hotpath-alloc", + "flowstate-docx/hotpath-alloc", + "flowstate-document/hotpath-alloc", + "flowstate-flow/hotpath-alloc", +] [lints] workspace = true diff --git a/crates/flowstate-soak/src/bin/flow_soak.rs b/crates/flowstate-soak/src/bin/flow_soak.rs new file mode 100644 index 00000000..b064b76b --- /dev/null +++ b/crates/flowstate-soak/src/bin/flow_soak.rs @@ -0,0 +1,281 @@ +//! Two-peer flow (.fl0) collaboration soak (flow spec build-order step 12). +//! +//! Both peers drive the REAL gated write path — structural `FlowIntent`s on +//! the board plus same-cell char-level typing through a `FlowCellAuthority` +//! (exactly what an attached `RichTextEditor` does) — and exchange Loro update +//! bytes in rounds like the session I/O pump. Prints wall-clock distributions +//! per stage and ends with the convergence proof: both maintained boards +//! byte-equal each other AND a fresh materialization of canonical state, and +//! the shared cell's text identical on both peers. +//! +//! ```text +//! cargo run -p flowstate-soak --release --bin flow_soak -- [--rounds N] [--keystrokes-per-round N] +//! ``` + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use clap::Parser; +use flowstate_collab::flow::{FlowCellAuthority, FlowDocHandle, FlowRuntime}; +use flowstate_collab::local_write::WriteGate; +use flowstate_flow::format::{CellId, FlowFormat, SheetId}; +use flowstate_flow::intents::{CellPlacement, CellSeed, FlowDropIntent, FlowIntent}; +use flowstate_flow::loro_projection::materialize_board; +use gpui_flowtext::local_intents::{InsertTextIntent, LocalIntent, LocalWriteAuthority as _, TextAnchor}; +use uuid::Uuid; + +/// Two-peer flow collaboration hotpath soak. +#[derive(Parser)] +#[command(name = "flow-soak")] +struct Cli { + /// Sync rounds (each round: typing + one structural op per peer, then a + /// full update exchange). + #[arg(long, default_value_t = 200)] + rounds: usize, + /// Same-cell keystrokes per peer per round. + #[arg(long, default_value_t = 8)] + keystrokes_per_round: usize, +} + +struct Peer { + name: &'static str, + handle: Arc, + authority: Arc, + #[allow(dead_code, reason = "keeps the gate alive for the handle's lifetime")] + gate: Arc>, +} + +impl Peer { + fn oplog_vv(&self) -> loro::VersionVector { + self + .handle + .with_test_runtime(|runtime| runtime.oplog_version_vector()) + } +} + +#[hotpath::main(functions_limit = 60)] +fn main() { + let cli = Cli::parse(); + run(cli.rounds, cli.keystrokes_per_round).expect("flow soak failed"); +} + +fn run(rounds: usize, keystrokes_per_round: usize) -> anyhow::Result<()> { + let build_profile = if cfg!(debug_assertions) { "debug" } else { "release" }; + println!("flow collab soak — build profile: {build_profile}, rounds: {rounds}, keystrokes/round/peer: {keystrokes_per_round}"); + + let (peers, sheet, shared_cell) = spawn_two_peers()?; + let [a, b] = peers; + + let mut commit_times = Vec::with_capacity(rounds * keystrokes_per_round * 2); + let mut structural_times = Vec::with_capacity(rounds * 2); + let mut import_times = Vec::with_capacity(rounds * 2); + let mut round_cells: Vec = Vec::new(); + + for round in 0..rounds { + // Concurrent same-cell typing on BOTH peers before any sync — the + // headline char-merge shape. + for (peer, seed_char) in [(&a, b'a'), (&b, b'n')] { + for i in 0..keystrokes_per_round { + let projection = peer + .authority + .canonical_projection() + .map_err(|error| anyhow::anyhow!("{error}"))?; + let paragraph = projection.ids.paragraph_ids[0]; + let started = Instant::now(); + peer + .authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(paragraph, usize::MAX), + text: ((seed_char + ((round + i) % 13) as u8) as char).to_string(), + style_override: None, + })) + .map_err(|error| anyhow::anyhow!("cell keystroke rejected: {error}"))?; + commit_times.push(started.elapsed()); + } + } + + // One structural op per peer: A appends a cell, B moves the shared cell + // relative to it (reorder-vs-edit, the second headline shape). + let new_cell = Uuid::new_v4(); + let started = Instant::now(); + a.handle + .apply(FlowIntent::AddCell { + sheet_id: sheet, + cell_id: new_cell, + placement: CellPlacement::ColumnEnd { column_index: 0 }, + seed: CellSeed::Empty, + }) + .map_err(|error| anyhow::anyhow!("add-cell rejected: {error}"))?; + structural_times.push(started.elapsed()); + round_cells.push(new_cell); + if let Some(target) = round_cells.get(round.saturating_sub(1)).copied() + && target != shared_cell + { + let started = Instant::now(); + // Racing against A's concurrent append — a rejection (target not yet + // known to B) is expected traffic, anything else is a bug. + match b.handle.apply(FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: shared_cell, + drop: FlowDropIntent::AfterSibling(target), + }) { + Ok(_) => structural_times.push(started.elapsed()), + Err(error) => { + let diagnostic = format!("{error}"); + anyhow::ensure!( + diagnostic.contains("cell") || diagnostic.contains("sheet"), + "unexpected move rejection: {diagnostic}" + ); + }, + } + } + + // Update exchange, both directions (the session pump shape). + for (from, to) in [(&a, &b), (&b, &a)] { + let vv = to.oplog_vv(); + let bytes = from + .handle + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)) + .map_err(|error| anyhow::anyhow!("export failed: {error:#}"))?; + if bytes.is_empty() { + continue; + } + let started = Instant::now(); + to.handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .map_err(|error| anyhow::anyhow!("import failed: {error:#}"))?; + import_times.push(started.elapsed()); + } + } + + // One quiescing double-exchange, then the convergence proof. + for _ in 0..2 { + for (from, to) in [(&a, &b), (&b, &a)] { + let vv = to.oplog_vv(); + let bytes = from + .handle + .with_test_runtime(|runtime| runtime.export_updates_for(&vv)) + .map_err(|error| anyhow::anyhow!("export failed: {error:#}"))?; + if !bytes.is_empty() { + to.handle + .with_test_runtime(|runtime| runtime.import_remote_updates(&[bytes.as_slice()])) + .map_err(|error| anyhow::anyhow!("import failed: {error:#}"))?; + } + } + } + + summarize("cell keystroke (authority commit, gate-held)", &mut commit_times); + summarize("structural intent (gate-held)", &mut structural_times); + summarize("remote import (gate-held)", &mut import_times); + + let board_a = a + .handle + .board_projection() + .map_err(|error| anyhow::anyhow!("{error}"))?; + let board_b = b + .handle + .board_projection() + .map_err(|error| anyhow::anyhow!("{error}"))?; + anyhow::ensure!(board_a == board_b, "peer boards diverged after quiescence"); + for peer in [&a, &b] { + let fresh = peer + .handle + .with_test_runtime(|runtime| materialize_board(runtime.doc()).map(|(board, _defects)| board)) + .map_err(|error| anyhow::anyhow!("fresh materialize failed: {error:#}"))?; + anyhow::ensure!(fresh == board_a, "{}: maintained board != fresh materialization", peer.name); + } + let text_a = cell_text(&a, shared_cell); + let text_b = cell_text(&b, shared_cell); + anyhow::ensure!(text_a == text_b, "shared cell text diverged"); + anyhow::ensure!( + text_a.chars().count() >= rounds * keystrokes_per_round * 2, + "typed characters were lost: {} < {}", + text_a.chars().count(), + rounds * keystrokes_per_round * 2, + ); + println!( + "\nconverged: {} cells, shared cell holds {} chars from both peers, boards byte-equal on both sides", + board_a.sheets[0].cells.len(), + text_a.chars().count(), + ); + Ok(()) +} + +fn spawn_two_peers() -> anyhow::Result<([Peer; 2], SheetId, CellId)> { + let format = FlowFormat::policy_debate(); + let (seed_handle, _seed_gate) = FlowDocHandle::new(FlowRuntime::new(&format)?); + let sheet = Uuid::new_v4(); + let cell = Uuid::new_v4(); + seed_handle + .apply(FlowIntent::CreateSheet { + sheet_id: sheet, + name: "Soak".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .map_err(|error| anyhow::anyhow!("seed sheet: {error}"))?; + seed_handle + .apply(FlowIntent::AddCell { + sheet_id: sheet, + cell_id: cell, + placement: CellPlacement::ColumnEnd { column_index: 0 }, + seed: CellSeed::Empty, + }) + .map_err(|error| anyhow::anyhow!("seed cell: {error}"))?; + let snapshot = seed_handle.with_test_runtime(|runtime| runtime.snapshot())?; + let peers: Vec = ["peer-a", "peer-b"] + .into_iter() + .map(|name| -> anyhow::Result { + let (handle, gate) = FlowDocHandle::new(FlowRuntime::from_snapshot(&snapshot)?); + let authority = handle.cell_authority(cell); + handle + .open_cell(cell) + .map_err(|error| anyhow::anyhow!("open cell: {error}"))?; + Ok(Peer { + name, + handle, + authority, + gate, + }) + }) + .collect::>()?; + let [a, b] = peers.try_into().ok().expect("two peers"); + Ok(([a, b], sheet, cell)) +} + +fn cell_text(peer: &Peer, cell: CellId) -> String { + peer.handle.with_test_runtime(|runtime| { + let rows = flowstate_flow::loro_projection::materialize_cell_rows(runtime.doc(), cell).expect("cell rows"); + rows + .blocks + .iter() + .filter_map(|block| match block { + gpui_flowtext::InputBlock::Paragraph(paragraph) => Some( + paragraph + .runs + .iter() + .map(|run| run.text.as_str()) + .collect::(), + ), + _ => None, + }) + .collect::>() + .join("\n") + }) +} + +fn summarize(label: &str, times: &mut [Duration]) { + if times.is_empty() { + println!("{label:<48} (no samples)"); + return; + } + times.sort_unstable(); + let pick = |q: f64| times[((times.len() - 1) as f64 * q) as usize]; + println!( + "{label:<48} n={:<5} p50={:>10.3?} p90={:>10.3?} p99={:>10.3?} max={:>10.3?}", + times.len(), + pick(0.50), + pick(0.90), + pick(0.99), + times[times.len() - 1], + ); +} diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index 8f4518dd..9a564d74 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -27,8 +27,8 @@ hotpath = { workspace = true } iroh = { workspace = true } loro = { workspace = true } memchr = "2.8.1" -mimalloc = { workspace = true } notify = { workspace = true } +palette = "0.7.6" rustc-hash = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -49,7 +49,6 @@ hotpath = [ "hotpath/hotpath", "flowstate-document/hotpath", "flowstate-docx/hotpath", - "flowstate-flow/hotpath", "flowstate-collab/hotpath", ] hotpath-cpu = [ @@ -57,7 +56,6 @@ hotpath-cpu = [ "hotpath/hotpath-cpu", "flowstate-document/hotpath-cpu", "flowstate-docx/hotpath-cpu", - "flowstate-flow/hotpath-cpu", "flowstate-collab/hotpath-cpu", ] hotpath-alloc = [ @@ -65,7 +63,6 @@ hotpath-alloc = [ "hotpath/hotpath-alloc", "flowstate-document/hotpath-alloc", "flowstate-docx/hotpath-alloc", - "flowstate-flow/hotpath-alloc", "flowstate-collab/hotpath-alloc", ] profiler = ["hotpath", "hotpath-alloc", "hotpath-cpu"] @@ -76,5 +73,11 @@ gpui-symbols = { workspace = true } [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] cosmic-text = { workspace = true } +[target.'cfg(not(windows))'.dependencies] +rimalloc = { git = "https://github.com/moggedhedien/rimalloc.git" } + +[target.'cfg(windows)'.dependencies] +mimalloc = { workspace = true } + [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/flowstate/assets/icons/file-input.svg b/crates/flowstate/assets/icons/file-input.svg new file mode 100644 index 00000000..39f81c6b --- /dev/null +++ b/crates/flowstate/assets/icons/file-input.svg @@ -0,0 +1 @@ + diff --git a/crates/flowstate/assets/icons/message-square-reply.svg b/crates/flowstate/assets/icons/message-square-reply.svg new file mode 100644 index 00000000..70d8f873 --- /dev/null +++ b/crates/flowstate/assets/icons/message-square-reply.svg @@ -0,0 +1 @@ + diff --git a/crates/flowstate/assets/icons/remove-formatting.svg b/crates/flowstate/assets/icons/remove-formatting.svg new file mode 100644 index 00000000..c9b41f22 --- /dev/null +++ b/crates/flowstate/assets/icons/remove-formatting.svg @@ -0,0 +1 @@ + diff --git a/crates/flowstate/assets/icons/replace-all.svg b/crates/flowstate/assets/icons/replace-all.svg new file mode 100644 index 00000000..8e8d8560 --- /dev/null +++ b/crates/flowstate/assets/icons/replace-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/flowstate/assets/icons/replace.svg b/crates/flowstate/assets/icons/replace.svg index ed209f37..68ddc9c3 100644 --- a/crates/flowstate/assets/icons/replace.svg +++ b/crates/flowstate/assets/icons/replace.svg @@ -1,3 +1 @@ - + \ No newline at end of file diff --git a/crates/flowstate/assets/icons/text-search.svg b/crates/flowstate/assets/icons/text-search.svg new file mode 100644 index 00000000..0f5cb948 --- /dev/null +++ b/crates/flowstate/assets/icons/text-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/flowstate/src/app.rs b/crates/flowstate/src/app.rs index b64129a4..ce894e6d 100644 --- a/crates/flowstate/src/app.rs +++ b/crates/flowstate/src/app.rs @@ -406,6 +406,7 @@ impl AssetSource for AppAssets { "icons/save.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/save.svg")))), "icons/bold.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/bold.svg")))), "icons/eraser.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/eraser.svg")))), + "icons/remove-formatting.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/remove-formatting.svg")))), "icons/highlighter.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/highlighter.svg")))), "icons/mark-card.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/mark-card.svg")))), "icons/shrink.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/shrink.svg")))), @@ -413,6 +414,8 @@ impl AssetSource for AppAssets { "icons/underline.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/underline.svg")))), "icons/archive.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/archive.svg")))), "icons/file-search-corner.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/file-search-corner.svg")))), + "icons/file-input.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/file-input.svg")))), + "icons/message-square-reply.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/message-square-reply.svg")))), "icons/notebook-text.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/notebook-text.svg")))), "icons/table-of-contents.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/table-of-contents.svg")))), "icons/panel-top-open.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/panel-top-open.svg")))), @@ -422,6 +425,7 @@ impl AssetSource for AppAssets { "icons/letter-case.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/letter-case.svg")))), "icons/text-box-edit.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/text-box-edit.svg")))), "icons/replace.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/replace.svg")))), + "icons/replace-all.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/replace-all.svg")))), "icons/pin.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/pin.svg")))), "icons/paragraph-break-two.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/paragraph-break-two.svg")))), "icons/send-to-back.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/send-to-back.svg")))), @@ -431,6 +435,7 @@ impl AssetSource for AppAssets { "icons/send-horizontal.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/send-horizontal.svg")))), "icons/pdf.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/pdf.svg")))), "icons/docx.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/docx.svg")))), + "icons/text-search.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/icons/text-search.svg")))), "logo/flowstate-app-icon.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/logo/flowstate-app-icon.svg")))), "logo/flowstate-app-icon-light.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/logo/flowstate-app-icon-light.svg")))), "logo/flowstate-mark.svg" => Ok(Some(Cow::Borrowed(include_bytes!("../assets/logo/flowstate-mark.svg")))), @@ -452,6 +457,9 @@ impl AssetSource for AppAssets { if "icons/eraser.svg".starts_with(path) { assets.push("icons/eraser.svg".into()); } + if "icons/remove-formatting.svg".starts_with(path) { + assets.push("icons/remove-formatting.svg".into()); + } if "icons/highlighter.svg".starts_with(path) { assets.push("icons/highlighter.svg".into()); } @@ -473,6 +481,12 @@ impl AssetSource for AppAssets { if "icons/file-search-corner.svg".starts_with(path) { assets.push("icons/file-search-corner.svg".into()); } + if "icons/file-input.svg".starts_with(path) { + assets.push("icons/file-input.svg".into()); + } + if "icons/message-square-reply.svg".starts_with(path) { + assets.push("icons/message-square-reply.svg".into()); + } if "icons/notebook-text.svg".starts_with(path) { assets.push("icons/notebook-text.svg".into()); } @@ -500,6 +514,9 @@ impl AssetSource for AppAssets { if "icons/replace.svg".starts_with(path) { assets.push("icons/replace.svg".into()); } + if "icons/replace-all.svg".starts_with(path) { + assets.push("icons/replace-all.svg".into()); + } if "icons/pin.svg".starts_with(path) { assets.push("icons/pin.svg".into()); } @@ -527,6 +544,9 @@ impl AssetSource for AppAssets { if "icons/docx.svg".starts_with(path) { assets.push("icons/docx.svg".into()); } + if "icons/text-search.svg".starts_with(path) { + assets.push("icons/text-search.svg".into()); + } if "logo/flowstate-app-icon.svg".starts_with(path) { assets.push("logo/flowstate-app-icon.svg".into()); } diff --git a/crates/flowstate/src/collab/asset_transfer.rs b/crates/flowstate/src/collab/asset_transfer.rs index 1eee4813..ed139d15 100644 --- a/crates/flowstate/src/collab/asset_transfer.rs +++ b/crates/flowstate/src/collab/asset_transfer.rs @@ -13,7 +13,7 @@ pub(super) fn schedule_missing_assets( preferred_peer: Option, cx: &mut Context, ) { - let Some(editor) = session.editor.clone() else { + let Some(editor) = session.rich_text_editor() else { tracing::trace!(session = %session.session, preferred_peer = ?preferred_peer, "skipping collaboration asset scan because editor is missing"); return; }; @@ -23,6 +23,9 @@ pub(super) fn schedule_missing_assets( }; let session_id = session.session; cx.spawn(async move |session, cx| { + let Some(runtime) = runtime.as_rich_text().cloned() else { + return; + }; let result = runtime.asset_metadata().await; let _ = session.update(cx, |session, cx| match result { Ok(assets) => schedule_missing_assets_from_metadata(session, editor, preferred_peer, assets, cx), diff --git a/crates/flowstate/src/collab/dropbox_checkpoint.rs b/crates/flowstate/src/collab/dropbox_checkpoint.rs index ed0591fd..cb57edcc 100644 --- a/crates/flowstate/src/collab/dropbox_checkpoint.rs +++ b/crates/flowstate/src/collab/dropbox_checkpoint.rs @@ -3,6 +3,7 @@ use std::{io, path::Path}; use flowstate_collab::{ doc_io::DocIoHandle, dropbox::{DropboxClient, DropboxWriteError}, + flow::{FlowIoHandle, FlowRuntime}, }; use loro::ExportMode; @@ -81,3 +82,72 @@ pub async fn sync_bound_checkpoint(local_path: &Path, title: String, io_handle: let refreshed = client.credentials().await; save_dropbox_collaboration(refreshed, root, true) } + +/// FLOW mirror of [`sync_bound_checkpoint`]: upload the raw `.fl0` container +/// to the bound Dropbox path (no named revisions — flows have none). On a +/// remote conflict the two Loro docs merge (CRDT import through the flow I/O +/// service), the local file is rewritten, and the merged container re-uploads +/// against the remote rev. The document-id gate prevents cross-document +/// clobbering, exactly like the .db8 path. +pub async fn sync_bound_flow_file(local_path: &Path, local_document_id: uuid::Uuid, io_handle: &FlowIoHandle) -> io::Result<()> { + let Some(mut binding) = load_dropbox_document_binding(local_path) else { + return Ok(()); + }; + let Some((credentials, root)) = load_dropbox_collaboration() else { + return Err(io::Error::new( + io::ErrorKind::NotConnected, + "flow is bound to Dropbox, but Dropbox collaboration is disconnected", + )); + }; + let client = DropboxClient::new(credentials); + let container = io_handle + .encode_bytes() + .await + .map_err(|error| io::Error::other(format!("encoding flow for Dropbox failed: {error:#}")))?; + let metadata = match client + .put_checkpoint(&binding.remote_path, container, binding.revision.as_deref()) + .await + { + Ok(metadata) => metadata, + Err(DropboxWriteError::Conflict { .. }) => { + let remote = client + .download(&binding.remote_path) + .await + .map_err(|error| io::Error::other(format!("Dropbox conflict download failed: {error:#}")))?; + let remote_snapshot = flowstate_flow::decode_fl0_snapshot(&remote.bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("Dropbox path is not a valid .fl0: {error:#}")))?; + let remote_runtime = FlowRuntime::from_snapshot(&remote_snapshot) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, format!("Dropbox path is not a valid flow: {error:#}")))?; + if remote_runtime.document_id() != local_document_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Dropbox path contains a different Flowstate flow; it was not overwritten", + )); + } + io_handle + .import_remote_update(remote_snapshot) + .await + .map_err(|error| io::Error::other(format!("merging Dropbox flow changes failed: {error:#}")))?; + io_handle + .save_to(local_path.to_path_buf()) + .await + .map_err(|error| io::Error::other(format!("saving merged Dropbox flow failed: {error:#}")))?; + let merged = io_handle + .encode_bytes() + .await + .map_err(|error| io::Error::other(format!("encoding merged Dropbox flow failed: {error:#}")))?; + client + .put_checkpoint(&binding.remote_path, merged, Some(&remote.metadata.rev)) + .await + .map_err(|error| io::Error::other(format!("Dropbox changed again during conflict recovery: {:#}", error.into_anyhow())))? + }, + Err(DropboxWriteError::Other(error)) => { + return Err(io::Error::other(format!("saved locally, but Dropbox upload failed: {error:#}"))); + }, + }; + binding.revision = Some(metadata.rev); + save_dropbox_document_binding(binding)?; + + let refreshed = client.credentials().await; + save_dropbox_collaboration(refreshed, root, true) +} diff --git a/crates/flowstate/src/collab/manager.rs b/crates/flowstate/src/collab/manager.rs index e5960779..76075e48 100644 --- a/crates/flowstate/src/collab/manager.rs +++ b/crates/flowstate/src/collab/manager.rs @@ -45,6 +45,7 @@ pub struct CollabManager { discovery_publications: HashMap, discovery_paths: HashMap, discovery_titles: HashMap, + session_kinds: HashMap, pub(super) endpoint_online: bool, pub(super) event_pump_started: bool, } @@ -85,6 +86,7 @@ impl CollabManager { self.discovery_publications.clear(); self.discovery_paths.clear(); self.discovery_titles.clear(); + self.session_kinds.clear(); self.event_pump_started = false; self.endpoint_online = false; tracing::debug!("collaboration runtime shutdown state cleared"); @@ -123,8 +125,68 @@ impl CollabManager { self.discovery_paths.insert(session, path); } self.discovery_titles.insert(session, title.clone()); + self + .session_kinds + .insert(session, flowstate_collab::DocumentKind::RichText); let collab = CollabSession::from_local_runtime(session, panel_id, editor, title, document_io, commands.clone()); let direct_handler = collab.direct_handler(); + self.finish_start_session(session, panel_id, collab, direct_handler, commands, cx) + } + + /// Start a session on an open FLOW tab (spec Part C): identical wiring, flow + /// I/O handle + flow editor arm. + pub fn start_flow_session_for_panel( + &mut self, + panel_id: Uuid, + editor: Entity, + title: String, + flow_io: flowstate_collab::flow::FlowIoHandle, + cx: &mut Context, + ) -> Result + where + T: 'static, + { + if let Some(session) = self.session_by_panel.get(&panel_id).copied() { + tracing::debug!(%panel_id, %session, "collaboration session already exists for panel"); + return Ok(session); + } + let commands = self.ensure_runtime(cx)?; + let session = SessionId::new(); + let canonical_document_id = editor + .read(cx) + .handle() + .document_id() + .map(|id| id.as_u128()) + .unwrap_or_default(); + let document_path = editor.read(cx).document_path().cloned(); + self + .discovery_documents + .insert(session, document_fingerprint(canonical_document_id)); + tracing::info!(%panel_id, %session, title = %title, "starting local collaboration flow session"); + if let Some(path) = document_path { + self.discovery_paths.insert(session, path); + } + self.discovery_titles.insert(session, title.clone()); + self + .session_kinds + .insert(session, flowstate_collab::DocumentKind::Flow); + let collab = CollabSession::from_local_flow_runtime(session, panel_id, editor, title, flow_io, commands.clone()); + let direct_handler = collab.direct_handler(); + self.finish_start_session(session, panel_id, collab, direct_handler, commands, cx) + } + + fn finish_start_session( + &mut self, + session: SessionId, + panel_id: Uuid, + collab: CollabSession, + direct_handler: flowstate_collab::net::direct::DirectSessionHandler, + commands: flowstate_collab::net::runtime::CommandSender, + cx: &mut Context, + ) -> Result + where + T: 'static, + { let entity = cx.new(|_| collab); entity.update(cx, |session, cx| session.attach(cx)); @@ -203,7 +265,15 @@ impl CollabManager { .first() .map(|addr| addr.id) .ok_or_else(|| anyhow!("collaboration invite has no reachable participants"))?; - let collab = CollabSession::joining(session, ticket.title.clone(), commands.clone(), bootstrap.clone(), admission.clone()); + self.session_kinds.insert(session, ticket.document); + let collab = CollabSession::joining( + session, + ticket.title.clone(), + commands.clone(), + bootstrap.clone(), + admission.clone(), + ticket.document, + ); let entity = cx.new(|_| collab); self.register_session(entity.clone(), cx); let neighbor_rx = entity.update(cx, |session, cx| session.prepare_join_neighbor_wait(cx)); @@ -232,7 +302,7 @@ impl CollabManager { &mut self, session_id: SessionId, panel_id: Uuid, - editor: Entity, + editor: super::session::CollabEditor, cx: &mut Context, ) -> Result<()> where @@ -309,6 +379,7 @@ impl CollabManager { let session_id = self.session_by_panel.get(&panel_id).copied()?; let session = self.sessions_by_id.get(&session_id)?.clone(); let title = session.read(cx).title().to_string(); + let document = session.read(cx).document_kind(); let commands = self.ensure_runtime(cx).ok()?; let (ticket_tx, ticket_rx) = async_channel::bounded(1); let (reply_tx, reply_rx) = async_channel::bounded(1); @@ -327,7 +398,7 @@ impl CollabManager { tracing::info!(%session_id, inviter = %minted.inviter.id, "created collaboration share ticket"); let own_endpoint_id = minted.inviter.id; let _ = cx.update_global::(|manager, _| manager.own_endpoint_id = Some(own_endpoint_id)); - Ok(SessionTicket::new(session_id, vec![minted.inviter], title, minted.admission)) + Ok(SessionTicket::new(session_id, vec![minted.inviter], title, minted.admission, document)) }, Ok(Err(error)) => { tracing::error!(%session_id, error = %format_args!("{error:#}"), "minting collaboration invite failed"); @@ -538,9 +609,15 @@ impl CollabManager { .cloned() .unwrap_or_else(|| "Shared document".into()); let identities = trusted_identity_keys_for_path(path); + let document = self + .session_kinds + .get(&session) + .copied() + .unwrap_or(flowstate_collab::ticket::DocumentKind::RichText); if let Err(error) = runtime .commands .try_send(NetCommand::ConfigureStandingAccess { + document, session, document_fingerprint, title, diff --git a/crates/flowstate/src/collab/mod.rs b/crates/flowstate/src/collab/mod.rs index 26c81357..1705d2eb 100644 --- a/crates/flowstate/src/collab/mod.rs +++ b/crates/flowstate/src/collab/mod.rs @@ -10,11 +10,11 @@ pub mod share_dialog; mod shutdown; pub mod status; -use std::{borrow::BorrowMut, sync::Arc}; +use std::borrow::BorrowMut; use anyhow::Result; use async_channel::Receiver; -use flowstate_collab::{SessionId, doc_io::DocIoHandle, local_write::LocalDocHandle, ticket::SessionTicket}; +use flowstate_collab::{SessionId, doc_io::DocIoHandle, ticket::SessionTicket}; use gpui::{App, BorrowAppContext, Context, Entity, ReadGlobal}; use uuid::Uuid; @@ -23,7 +23,8 @@ use crate::rich_text_element::RichTextEditor; pub use discovery_runtime::DiscoveryScanResult; pub use manager::{CollabManager, JoinRequest}; pub use session::{ - Attachment, CollabSession, Connectivity, DetachReason, JoinStage, JoinedDocument, SessionNotice, SessionPhase, SessionRosterEntry, + Attachment, CollabEditor, CollabSession, Connectivity, DetachReason, JoinStage, JoinedAuthority, JoinedDocument, JoinedDocumentPayload, + SessionNotice, SessionPhase, SessionRosterEntry, }; pub use shutdown::shutdown; @@ -47,12 +48,29 @@ where cx.update_default_global::(|manager, cx| manager.start_session_for_panel(panel_id, editor, title, io, cx)) } +/// Flow-tab variant of [`start_session_for_panel`] (spec Part C). +pub fn start_flow_session_for_panel( + panel_id: Uuid, + editor: Entity, + title: String, + io: flowstate_collab::flow::FlowIoHandle, + cx: &mut Context, +) -> Result +where + T: 'static, +{ + cx.update_default_global::(|manager, cx| manager.start_flow_session_for_panel(panel_id, editor, title, io, cx)) +} + /// JOIN handoff (Loro-first spec §3): take the document services the joining /// session constructed from the initial snapshot — the write authority for the /// editor and the I/O handle for saves/exports. One-shot: the write authority /// moves out of the session (the session is transport-only, invariant 5); /// subsequent calls return `None`. -pub fn take_joined_document_services_for_session(session_id: SessionId, cx: &mut Context) -> Option<(Arc, DocIoHandle)> +pub fn take_joined_document_services_for_session( + session_id: SessionId, + cx: &mut Context, +) -> Option<(session::JoinedAuthority, flowstate_collab::SyncIoHandle)> where T: 'static, { @@ -69,7 +87,7 @@ where cx.update_default_global::(|manager, cx| manager.join_session(ticket, cx)) } -pub fn attach_joined_session(session_id: SessionId, panel_id: Uuid, editor: Entity, cx: &mut Context) -> Result<()> +pub fn attach_joined_session(session_id: SessionId, panel_id: Uuid, editor: session::CollabEditor, cx: &mut Context) -> Result<()> where T: 'static, { diff --git a/crates/flowstate/src/collab/presence_view.rs b/crates/flowstate/src/collab/presence_view.rs index 513a47a7..46d9124c 100644 --- a/crates/flowstate/src/collab/presence_view.rs +++ b/crates/flowstate/src/collab/presence_view.rs @@ -12,6 +12,11 @@ pub fn collaboration_recovery_path(session: SessionId, title: &str) -> PathBuf { dir.join(format!("{prefix}-{}.db8", sanitized_recovery_title(title))) } +/// Flow-session variant: same directory/naming law, `.fl0` container. +pub fn collaboration_flow_recovery_path(session: SessionId, title: &str) -> PathBuf { + collaboration_recovery_path(session, title).with_extension("fl0") +} + fn sanitized_recovery_title(title: &str) -> String { let mut out = String::new(); for ch in title.chars() { diff --git a/crates/flowstate/src/collab/session.rs b/crates/flowstate/src/collab/session.rs index a8f03cb3..a315c5bb 100644 --- a/crates/flowstate/src/collab/session.rs +++ b/crates/flowstate/src/collab/session.rs @@ -6,9 +6,10 @@ use std::{ use anyhow::{Context as _, Result, anyhow, bail}; use flowstate_collab::{ - SessionAdmission, SessionId, + DocumentKind, SessionAdmission, SessionId, SyncIoHandle, crdt_runtime::{CrdtRuntime, RuntimeEvent}, doc_io::DocIoHandle, + flow::{FlowDocHandle, FlowIoHandle, FlowPublishEvent, FlowRuntime}, ids::PeerId, local_write::{LocalDocHandle, LocalWriteConfig}, net::{ @@ -26,6 +27,7 @@ use loro::{LoroDoc, Subscription as LoroSubscription}; use uuid::Uuid; use crate::app_settings::{load_document_theme, load_local_user_identity, load_local_user_profile}; +use crate::flow::{FlowEditor, FlowEditorEvent}; use crate::rich_text_element::{AssetId, AssetRecord, CollaborationRole, DocumentProjection, EditorEvent, RichTextEditor}; use super::presence_view; @@ -94,7 +96,43 @@ pub(super) enum JoinNeighborSignal { pub struct JoinedDocument { pub session: SessionId, pub title: String, - pub document: DocumentProjection, + pub payload: JoinedDocumentPayload, +} + +/// The join snapshot's materialization, per document kind. +pub enum JoinedDocumentPayload { + RichText(Box), + Flow(flowstate_flow::FlowBoardProjection), +} + +/// The session's document-facing editor arm (spec Part C): ONE session type, +/// two-arm document half. +#[derive(Clone)] +pub enum CollabEditor { + RichText(Entity), + Flow(Entity), +} + +impl CollabEditor { + pub fn as_rich_text(&self) -> Option<&Entity> { + match self { + Self::RichText(editor) => Some(editor), + Self::Flow(_) => None, + } + } + + pub fn as_flow(&self) -> Option<&Entity> { + match self { + Self::Flow(editor) => Some(editor), + Self::RichText(_) => None, + } + } +} + +/// The parked join-handoff write authority, per document kind. +pub enum JoinedAuthority { + RichText(Arc), + Flow(Arc), } #[derive(Clone, Debug)] @@ -111,13 +149,14 @@ pub struct CollabSession { // Loro-first (spec §3, invariant 5): the session is TRANSPORT-ONLY. It holds // the document I/O service handle to import remote updates and drain the // publish queue — never a write path into the document. - runtime: Option, + runtime: Option, // JOIN handoff slot (spec §3 join gate): the write authority constructed by // `finish_join_snapshot`, held ONLY until the workspace takes it via // `take_joined_document_services`. The session never calls into it. - join_authority: Option>, + join_authority: Option, runtime_vv: Vec, - editor: Option>, + kind: DocumentKind, + editor: Option, panel_id: Option, // UI-only asset cache records fetched after Loro metadata arrives. pending_asset_records: Vec<(AssetId, AssetRecord)>, @@ -179,6 +218,48 @@ impl CollabSession { title: String, io: DocIoHandle, net_tx: CommandSender, + ) -> Self { + Self::from_local_parts( + session, + panel_id, + CollabEditor::RichText(editor), + title, + SyncIoHandle::RichText(io), + DocumentKind::RichText, + net_tx, + ) + } + + /// Start a session on an open FLOW tab: the identical wiring shape (the + /// editor already holds its write authority; the session only takes the + /// transport-side flow I/O handle). + pub fn from_local_flow_runtime( + session: SessionId, + panel_id: Uuid, + editor: Entity, + title: String, + io: FlowIoHandle, + net_tx: CommandSender, + ) -> Self { + Self::from_local_parts( + session, + panel_id, + CollabEditor::Flow(editor), + title, + SyncIoHandle::Flow(io), + DocumentKind::Flow, + net_tx, + ) + } + + fn from_local_parts( + session: SessionId, + panel_id: Uuid, + editor: CollabEditor, + title: String, + io: SyncIoHandle, + kind: DocumentKind, + net_tx: CommandSender, ) -> Self { // Direct serving is network-bound and should backpressure rather than grow without limit. let (direct_tx, direct_rx) = async_channel::bounded(DIRECT_REQUEST_CHANNEL_CAPACITY); @@ -197,6 +278,7 @@ impl CollabSession { runtime: Some(io), join_authority: None, runtime_vv: Vec::new(), + kind, editor: Some(editor), panel_id: Some(panel_id), pending_asset_records: Vec::new(), @@ -239,7 +321,14 @@ impl CollabSession { } } - pub fn joining(session: SessionId, title: String, net_tx: CommandSender, bootstrap_addrs: Vec, admission: SessionAdmission) -> Self { + pub fn joining( + session: SessionId, + title: String, + net_tx: CommandSender, + bootstrap_addrs: Vec, + admission: SessionAdmission, + kind: DocumentKind, + ) -> Self { let collaboration_role = CollaborationRole::Editor; let (direct_tx, direct_rx) = async_channel::bounded(DIRECT_REQUEST_CHANNEL_CAPACITY); let now = Instant::now(); @@ -251,6 +340,7 @@ impl CollabSession { runtime: None, join_authority: None, runtime_vv: Vec::new(), + kind, editor: None, panel_id: None, pending_asset_records: Vec::new(), @@ -305,6 +395,29 @@ impl CollabSession { &self.title } + /// The session's document kind (rides the v4 invite ticket under its HMAC). + pub fn document_kind(&self) -> DocumentKind { + self.kind + } + + /// The rich-text arm of the attached editor (presence/comments/assets are + /// rich-text-only surfaces; flow arms no-op through this accessor). + pub(super) fn rich_text_editor(&self) -> Option> { + self + .editor + .as_ref() + .and_then(CollabEditor::as_rich_text) + .cloned() + } + + pub(super) fn flow_editor(&self) -> Option> { + self + .editor + .as_ref() + .and_then(CollabEditor::as_flow) + .cloned() + } + pub(super) fn set_admission(&mut self, admission: SessionAdmission) { self.admission = Some(admission); } @@ -515,7 +628,7 @@ impl CollabSession { Ok(reply_rx) } - pub fn attach_joined_editor(&mut self, panel_id: Uuid, editor: Entity, cx: &mut Context) -> Result<()> { + pub fn attach_joined_editor(&mut self, panel_id: Uuid, editor: CollabEditor, cx: &mut Context) -> Result<()> { if self.runtime.is_none() { tracing::warn!(session = %self.session, %panel_id, "cannot attach joined editor before snapshot load finishes"); bail!("collaboration snapshot has not finished loading"); @@ -577,7 +690,7 @@ impl CollabSession { /// (the session must never hold a write path once attached) and the I/O /// handle is shared. Returns `None` before the snapshot import completes or /// after the services were already taken. - pub fn take_joined_document_services(&mut self) -> Option<(Arc, DocIoHandle)> { + pub fn take_joined_document_services(&mut self) -> Option<(JoinedAuthority, SyncIoHandle)> { let io = self.runtime.clone()?; let authority = self.join_authority.take()?; Some((authority, io)) @@ -697,7 +810,7 @@ impl CollabSession { } else { tracing::debug!(session = %self.session, peer = %peer, "local collaboration peer presence already established"); } - if let (Some(editor), Some(presence)) = (self.editor.clone(), self.presence.as_ref()) { + if let (Some(editor), Some(presence)) = (self.rich_text_editor(), self.presence.as_ref()) { editor.update(cx, |editor, cx| editor.set_own_collaboration_caret_color(Some(presence.self_color()), cx)); } self.refresh_own_presence(cx); @@ -714,7 +827,7 @@ impl CollabSession { } fn finish_join_snapshot(&mut self, snapshot: &[u8], cx: &mut Context) -> Result { - tracing::info!(session = %self.session, snapshot_bytes = snapshot.len(), "building collaboration document from join snapshot"); + tracing::info!(session = %self.session, snapshot_bytes = snapshot.len(), kind = ?self.kind, "building collaboration document from join snapshot"); if matches!(self.phase, SessionPhase::Detached(_)) { bail!("collaboration join is no longer active"); } @@ -727,6 +840,10 @@ impl CollabSession { self.phase = SessionPhase::Joining(JoinStage::Building); cx.notify(); + if matches!(self.kind, DocumentKind::Flow) { + return self.finish_join_flow_snapshot(snapshot); + } + let doc = LoroDoc::new(); flowstate_document::loro_schema::configure_text_styles(&doc); doc @@ -742,7 +859,8 @@ impl CollabSession { // cannot receive it before this point, i.e. before the initial snapshot // import completed). let (authority, gate) = LocalDocHandle::new(runtime, LocalWriteConfig::default()); - let io = DocIoHandle::spawn(gate).context("starting joined collaboration document I/O service")?; + let doc_io = DocIoHandle::spawn(gate).context("starting joined collaboration document I/O service")?; + let io = doc_io.clone(); // §15/§31: bind this joiner's durable author identity to the joined // document so their revisions record an author and `users_by_id` is // populated. The user-registration op converges to peers via @@ -750,7 +868,7 @@ impl CollabSession { // the join. `create_document_panel` receives this document through the // `Attachment` source, which deliberately skips re-binding to avoid a // redundant second call. - let identity_io = io.clone(); + let identity_io = doc_io.clone(); cx.spawn(async move |session, cx| { let (user_id, display_name) = cx .background_executor() @@ -778,8 +896,8 @@ impl CollabSession { "built collaboration document from join snapshot", ); - self.runtime = Some(io); - self.join_authority = Some(Arc::new(authority)); + self.runtime = Some(SyncIoHandle::RichText(io)); + self.join_authority = Some(JoinedAuthority::RichText(Arc::new(authority))); fidelity::event(FidelityClass::Frontier, "runtime-vv-reset", || { format!("session={} source=join-snapshot prior_bytes={}", self.session, self.runtime_vv.len()) }); @@ -787,7 +905,33 @@ impl CollabSession { Ok(JoinedDocument { session: self.session, title: format!("{} (shared)", self.title), - document, + payload: JoinedDocumentPayload::RichText(Box::new(document)), + }) + } + + /// The FLOW join arm (spec Part C): `FlowRuntime::from_snapshot` (schema + /// validated — the wrong-kind defense) → `FlowDocHandle::new` → + /// `FlowIoHandle::spawn` → park the authority for the workspace handoff. + fn finish_join_flow_snapshot(&mut self, snapshot: &[u8]) -> Result { + let runtime = FlowRuntime::from_snapshot(snapshot).context("building joined flow runtime from snapshot")?; + let board = runtime.board_ref().clone(); + let (authority, gate) = FlowDocHandle::new(runtime); + let io = FlowIoHandle::spawn(gate).context("starting joined flow I/O service")?; + tracing::info!( + session = %self.session, + sheets = board.sheets.len(), + "built collaboration flow document from join snapshot", + ); + self.runtime = Some(SyncIoHandle::Flow(io)); + self.join_authority = Some(JoinedAuthority::Flow(authority)); + fidelity::event(FidelityClass::Frontier, "runtime-vv-reset", || { + format!("session={} source=join-snapshot prior_bytes={}", self.session, self.runtime_vv.len()) + }); + self.runtime_vv.clear(); + Ok(JoinedDocument { + session: self.session, + title: format!("{} (shared)", self.title), + payload: JoinedDocumentPayload::Flow(board), }) } @@ -819,13 +963,22 @@ impl CollabSession { // document's write authority, gate, and I/O service are untouched — the // editor keeps editing through the identical path; nothing to reset on it // beyond collaboration presentation state. - if let Some(editor) = self.editor.clone() { - editor.update(cx, |editor, cx| { - editor.set_recovery_path(None, cx); - editor.set_collaboration_role(None, cx); - editor.set_own_collaboration_caret_color(None, cx); - editor.set_external_carets(Vec::new(), cx); - }); + match self.editor.clone() { + Some(CollabEditor::RichText(editor)) => { + editor.update(cx, |editor, cx| { + editor.set_recovery_path(None, cx); + editor.set_collaboration_role(None, cx); + editor.set_own_collaboration_caret_color(None, cx); + editor.set_external_carets(Vec::new(), cx); + }); + }, + Some(CollabEditor::Flow(editor)) => { + editor.update(cx, |editor, cx| { + editor.set_recovery_path(None, cx); + editor.set_external_presences(Vec::new(), cx); + }); + }, + None => {}, } self.editor_subscriptions.clear(); @@ -878,30 +1031,80 @@ impl CollabSession { return; }; let session_id = self.session; - cx.spawn(async move |session, cx| { - match io.pump_publish().await { - Ok(events) => { - if events.is_empty() { - return; + match io { + SyncIoHandle::RichText(io) => { + cx.spawn(async move |session, cx| { + match io.pump_publish().await { + Ok(events) => { + if events.is_empty() { + return; + } + let published = events.len(); + let _ = session.update(cx, |session, cx| { + fidelity::event(FidelityClass::Reconcile, "publish-pump", || { + format!("session={session_id} events={published}") + }); + if let Err(error) = session.apply_runtime_events(events, false, cx) { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "publishing committed local collaboration updates failed"); + return; + } + session.refresh_external_carets(cx); + }); + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping collaboration local update publish queue failed"); + }, } - let published = events.len(); - let _ = session.update(cx, |session, cx| { - fidelity::event(FidelityClass::Reconcile, "publish-pump", || { - format!("session={session_id} events={published}") - }); - if let Err(error) = session.apply_runtime_events(events, false, cx) { - tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "publishing committed local collaboration updates failed"); + }) + .detach(); + }, + SyncIoHandle::Flow(io) => { + cx.spawn(async move |session, cx| match io.pump_publish().await { + Ok(events) => { + if events.is_empty() { return; } - session.refresh_external_carets(cx); - }); + let published = events.len(); + let _ = session.update(cx, |session, cx| { + fidelity::event(FidelityClass::Reconcile, "publish-pump", || { + format!("session={session_id} kind=flow events={published}") + }); + session.apply_flow_events(events, cx); + }); + }, + Err(error) => { + tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping flow publish queue failed"); + }, + }) + .detach(); + }, + } + } + + /// The flow arm of the publish/apply pump: `FlowPublishEvent`s ride the + /// SAME vv-tracking + gossip publication the rich-text events use. + pub(super) fn apply_flow_events(&mut self, events: Vec, cx: &mut Context) { + for event in events { + match event { + FlowPublishEvent::LocalUpdate { bytes, version_vector, .. } => { + self.update_runtime_vv(version_vector, "local-update", true); + self.publish_update_bytes(bytes); }, - Err(error) => { - tracing::warn!(session = %session_id, error = %format_args!("{error:#}"), "pumping collaboration local update publish queue failed"); + FlowPublishEvent::RemoteUpdateApplied { version_vector, .. } => { + self.update_runtime_vv(version_vector, "remote-update-applied", true); }, } - }) - .detach(); + } + self.last_document_activity = Instant::now(); + // The projection changes ride the ordered board/cell streams; pump the + // flow editor to drain them. + if let Some(editor) = self.flow_editor() { + editor.update(cx, |editor, cx| { + editor.sync_board_from_handle(cx); + editor.sync_cell_editors_from_authority(cx); + }); + } + cx.notify(); } pub(super) fn apply_runtime_events(&mut self, events: Vec, apply_projection: bool, cx: &mut Context) -> Result<()> { @@ -982,7 +1185,7 @@ impl CollabSession { self.apply_runtime_projection(*document, cx)?; }, RuntimeEvent::SelectionRestored { selection } if apply_projection => { - if let Some(editor) = self.editor.clone() { + if let Some(editor) = self.rich_text_editor() { editor.update(cx, |editor, cx| editor.restore_runtime_selection(selection, cx)); } }, @@ -1004,7 +1207,7 @@ impl CollabSession { } fn apply_runtime_patches(&mut self, batch: flowstate_document::ProjectionPatchBatch, cx: &mut Context) -> Result<()> { - let Some(editor) = self.editor.clone() else { + let Some(editor) = self.rich_text_editor() else { return Ok(()); }; fidelity::event(FidelityClass::Projection, "apply-projection-patches", || { @@ -1028,7 +1231,7 @@ impl CollabSession { } fn apply_runtime_projection(&mut self, mut document: DocumentProjection, cx: &mut Context) -> Result<()> { - let Some(editor) = self.editor.clone() else { + let Some(editor) = self.rich_text_editor() else { return Ok(()); }; fidelity::event(FidelityClass::Projection, "apply-projection", || { @@ -1160,6 +1363,13 @@ impl CollabSession { tracing::warn!(session = %self.session, "cannot attach collaboration editor hooks because editor is missing"); return; }; + let editor = match editor { + CollabEditor::RichText(editor) => editor, + CollabEditor::Flow(editor) => { + self.attach_flow_editor_hooks(&editor, cx); + return; + }, + }; tracing::debug!(session = %self.session, "attaching collaboration editor hooks"); editor.update(cx, |editor, cx| { @@ -1218,6 +1428,35 @@ impl CollabSession { tracing::debug!(session = %self.session, subscriptions = self.editor_subscriptions.len(), "collaboration editor hooks attached"); } + /// FLOW editor hooks (spec Part C): observe → debounced publish pump; + /// pathless joined tabs get a debounced `.fl0` recovery file. + fn attach_flow_editor_hooks(&mut self, editor: &Entity, cx: &mut Context) { + tracing::debug!(session = %self.session, "attaching collaboration flow editor hooks"); + editor.update(cx, |editor, cx| { + if editor.document_path().is_none() { + editor.set_recovery_path(Some(presence_view::collaboration_flow_recovery_path(self.session, &self.title)), cx); + } + }); + self + .editor_subscriptions + .push(cx.observe(editor, |session, _, cx| { + session.schedule_publish_pump(cx); + })); + self + .editor_subscriptions + .push(cx.subscribe(editor, |session, _, event: &FlowEditorEvent, cx| { + match event { + FlowEditorEvent::Changed => session.schedule_publish_pump(cx), + FlowEditorEvent::ActiveCellChanged(_) | FlowEditorEvent::ActiveSheetChanged(_) | FlowEditorEvent::CellSelectionChanged => { + // Step 11 (presence): board focus + cell caret ride the presence + // channel. + session.schedule_own_presence_refresh(cx); + }, + } + })); + tracing::debug!(session = %self.session, subscriptions = self.editor_subscriptions.len(), "collaboration flow editor hooks attached"); + } + fn schedule_publish_pump(&mut self, cx: &mut Context) { if self.publish_pump_pending { return; diff --git a/crates/flowstate/src/collab/session_io.rs b/crates/flowstate/src/collab/session_io.rs index 6fa508dc..73e798f2 100644 --- a/crates/flowstate/src/collab/session_io.rs +++ b/crates/flowstate/src/collab/session_io.rs @@ -64,10 +64,31 @@ impl CollabSession { } tracing::debug!(session = %self.session, bytes = bytes.len(), "importing remote collaboration update"); - let io = self + let io = match self .runtime .clone() - .context("collaboration session has no document I/O service")?; + .context("collaboration session has no document I/O service")? + { + flowstate_collab::SyncIoHandle::RichText(io) => io, + flowstate_collab::SyncIoHandle::Flow(io) => { + // FLOW arm: one import request; the derivation rides the runtime's + // streams and the pump drains vv/publish events afterwards. + let session_id = self.session; + cx.spawn(async move |session, cx| match io.import_remote_update(bytes).await { + Ok(()) => { + let _ = session.update(cx, |session, cx| { + session.pump_publish(cx); + session.last_document_activity = std::time::Instant::now(); + }); + }, + Err(error) => { + tracing::error!(session = %session_id, error = %format_args!("{error:#}"), "dropped unimportable remote flow update"); + }, + }) + .detach(); + return Ok(()); + }, + }; // §perf: bytes is already owned; hand it straight to the I/O service with no extra copy. let bytes_len = bytes.len(); let session_id = self.session; @@ -254,8 +275,8 @@ impl CollabSession { } pub(super) fn flush_pending_asset_records(&mut self, cx: &mut Context) -> bool { - let Some(editor) = self.editor.clone() else { - tracing::trace!(session = %self.session, pending_asset_records = self.pending_asset_records.len(), "cannot flush collaboration asset records because editor is missing"); + let Some(editor) = self.rich_text_editor() else { + tracing::trace!(session = %self.session, pending_asset_records = self.pending_asset_records.len(), "cannot flush collaboration asset records because no rich-text editor is attached"); return false; }; let deferred = editor.read(cx).projection_apply_deferred(); @@ -337,9 +358,8 @@ impl CollabSession { fn asset_bytes(&self, asset: u128, cx: &mut Context) -> Result { let editor = self - .editor - .as_ref() - .context("collaboration session has no editor")?; + .rich_text_editor() + .context("collaboration session has no rich-text editor (flow sessions carry no assets)")?; let bytes = editor .read(cx) .document() @@ -365,7 +385,11 @@ impl CollabSession { // replica's own saves/packages include them. The session still never // writes document CONTENT (invariant 5) — asset bytes are content- // addressed sideband records. - if let Some(io) = self.runtime.clone() { + if let Some(io) = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()) + { let records: Vec = asset_records .iter() .map(|(_, record)| record.clone()) diff --git a/crates/flowstate/src/collab/session_presence.rs b/crates/flowstate/src/collab/session_presence.rs index 9b814eed..a313e49a 100644 --- a/crates/flowstate/src/collab/session_presence.rs +++ b/crates/flowstate/src/collab/session_presence.rs @@ -74,11 +74,32 @@ impl CollabSession { tracing::trace!(session = %self.session, "skipping own collaboration presence refresh because store is missing"); return; } + // FLOW arm: board focus + exact cell caret are read synchronously (the + // flow gate encodes cursors inline, no I/O-thread round trip needed). + if let Some(flow) = self.flow_editor() { + let focus = flow.read(cx).presence_focus(cx); + let state = PresenceState { + name: default_presence_name(), + color_rgb: crate::app_settings::load_local_user_profile().color_rgb, + focus: Some(flowstate_collab::presence::PresenceFocus::Flow(focus)), + }; + if let Some(presence) = &self.presence + && let Err(error) = presence.set_self(&state) + { + tracing::warn!(session = %self.session, error = %format_args!("{error:#}"), "collaboration flow presence encode failed"); + } + if self.refresh_peer_count() { + cx.notify(); + } + return; + } let selection = self - .editor - .as_ref() + .rich_text_editor() .map(|editor| editor.read(cx).selection().clone()); - let runtime = self.runtime.clone(); + let runtime = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()); let session_id = self.session; self.presence_refresh_generation = self.presence_refresh_generation.wrapping_add(1); let generation = self.presence_refresh_generation; @@ -96,9 +117,9 @@ impl CollabSession { let state = PresenceState { name: default_presence_name(), color_rgb: crate::app_settings::load_local_user_profile().color_rgb, - selection, + focus: selection.map(flowstate_collab::presence::PresenceFocus::Text), }; - tracing::trace!(session = %session_id, has_selection = state.selection.is_some(), "refreshing own collaboration presence"); + tracing::trace!(session = %session_id, has_focus = state.focus.is_some(), "refreshing own collaboration presence"); if let Some(presence) = &session.presence && let Err(error) = presence.set_self(&state) { @@ -159,8 +180,14 @@ impl CollabSession { if self.comment_annotation_refresh_pending { return; } - let Some(runtime) = self.runtime.clone() else { return }; - let Some(editor) = self.editor.clone() else { return }; + let Some(runtime) = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()) + else { + return; + }; + let Some(editor) = self.rich_text_editor() else { return }; self.comment_annotation_refresh_pending = true; self.comment_annotation_refresh_generation = self.comment_annotation_refresh_generation.wrapping_add(1); let generation = self.comment_annotation_refresh_generation; @@ -176,9 +203,8 @@ impl CollabSession { match result { Ok(comments) if session - .editor - .as_ref() - .is_some_and(|current| current == &editor) => + .rich_text_editor() + .is_some_and(|current| current == editor) => { let selections = comments .into_iter() @@ -204,12 +230,39 @@ impl CollabSession { tracing::trace!(session = %self.session, "skipping external caret refresh because presence is missing"); return; }; - let Some(runtime) = self.runtime.clone() else { - tracing::trace!(session = %self.session, "skipping external caret refresh because Loro doc is missing"); + // FLOW arm: remote board focus (sheet/cell/editing + caret) paints via the + // flow editor, which owns cell bounds and open cell editors. + if let Some(flow) = self.flow_editor() { + let self_key = presence.self_key(); + let presences: Vec = presence + .roster() + .into_iter() + .filter(|entry| entry.key != self_key) + .filter_map(|entry| { + let focus = entry.focus.as_ref()?.flow()?.clone(); + Some(crate::flow::FlowExternalPresence { + name: entry.name, + color_rgb: entry.color_rgb, + sheet: focus.sheet, + cell: focus.cell, + editing: focus.editing, + caret: focus.caret, + }) + }) + .collect(); + flow.update(cx, |flow, cx| flow.set_external_presences(presences, cx)); + return; + } + let Some(runtime) = self + .runtime + .clone() + .and_then(|io| io.as_rich_text().cloned()) + else { + tracing::trace!(session = %self.session, "skipping external caret refresh because no rich-text runtime is attached"); return; }; - let Some(editor) = self.editor.clone() else { - tracing::trace!(session = %self.session, "skipping external caret refresh because editor is missing"); + let Some(editor) = self.rich_text_editor() else { + tracing::trace!(session = %self.session, "skipping external caret refresh because no rich-text editor is attached"); return; }; // §perf: self_key is only used for equality; borrow it instead of allocating a String. @@ -220,7 +273,8 @@ impl CollabSession { .filter(|entry| entry.key != self_key) .filter_map(|entry| { entry - .selection + .focus + .and_then(|focus| focus.text().cloned()) .map(|selection| RuntimePresenceCaretRequest { selection, color_rgb: entry.color_rgb, @@ -248,9 +302,8 @@ impl CollabSession { } tracing::trace!(session = %session_id, carets = resolved.carets.len(), "refreshing collaboration external carets"); if session - .editor - .as_ref() - .is_some_and(|current| current == &editor) + .rich_text_editor() + .is_some_and(|current| current == editor) { if fidelity::enabled() { // A resolved external caret must land inside the editor's current diff --git a/crates/flowstate/src/commands.rs b/crates/flowstate/src/commands.rs index 78d981f7..a85f4fb4 100644 --- a/crates/flowstate/src/commands.rs +++ b/crates/flowstate/src/commands.rs @@ -2,6 +2,7 @@ mod keymap; use gpui::actions; +pub(crate) use keymap::action_for_command; pub use keymap::{Keymap, KeymapEntry, register_default_keybindings, register_keymap}; actions!(flowstate_workspace, [FindInDocumentAction, FidelityMarkAction]); @@ -107,6 +108,9 @@ pub enum CommandId { SwitchToTab9, SwitchToTab10, ScrollToParagraph, + FlowAddSiblingAbove, + FlowDeleteSelected, + FlowStrike, } #[derive(Clone, Copy, Debug)] @@ -178,8 +182,8 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::Cut, "Cut", EDITOR, &["cmd-x", "ctrl-x"]), CommandSpec::new(CommandId::Paste, "Paste", EDITOR, &["cmd-v", "ctrl-v"]), CommandSpec::new(CommandId::Save, "Save", EDITOR, &["cmd-s", "ctrl-s"]), - CommandSpec::new(CommandId::Undo, "Undo", EDITOR, &["ctrl-z", "cmd-z"]), - CommandSpec::new(CommandId::Redo, "Redo", EDITOR, &["ctrl-y", "cmd-shift-z", "ctrl-shift-z"]), + CommandSpec::new(CommandId::Undo, "Undo", EDITOR, &["cmd-z", "ctrl-z"]), + CommandSpec::new(CommandId::Redo, "Redo", EDITOR, &["cmd-shift-z", "ctrl-shift-z", "ctrl-y"]), CommandSpec::new(CommandId::SetParagraphPocket, "Set Paragraph: Pocket", EDITOR, &["f4"]), CommandSpec::new(CommandId::SetParagraphHat, "Set Paragraph: Hat", EDITOR, &["f5"]), CommandSpec::new(CommandId::SetParagraphBlock, "Set Paragraph: Block", EDITOR, &["f6"]), @@ -218,11 +222,11 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::NextTab, "Next Tab", APP, &["ctrl-tab"]), CommandSpec::new(CommandId::PreviousTab, "Previous Tab", APP, &["ctrl-shift-tab"]), CommandSpec::new(CommandId::TogglePinTab, "Toggle Pin Tab", APP, &["ctrl-shift-p"]), - CommandSpec::new(CommandId::SendToSpeechDocument, "Send to Speech Document", APP, &["ctrl-`"]), - CommandSpec::new(CommandId::SendToSpeechDocumentEnd, "Send to Speech Document End", APP, &["ctrl-~"]), + CommandSpec::new(CommandId::SendToSpeechDocument, "Send to Speech Document", APP, &["`"]), + CommandSpec::new(CommandId::SendToSpeechDocumentEnd, "Send to Speech Document End", APP, &["alt-`"]), CommandSpec::new(CommandId::CondenseSelection, "Condense Selection", APP, &["f3"]), CommandSpec::new(CommandId::MarkCard, "Mark Card", APP, &["ctrl-m"]), - CommandSpec::new(CommandId::CondensedSelection, "Shrink", APP, &[]), + CommandSpec::new(CommandId::CondensedSelection, "Shrink", APP, &["ctrl-8"]), CommandSpec::new(CommandId::ToggleSpeechDocument, "Toggle Speech Document", APP, &[]), CommandSpec::new(CommandId::ExportFormat, "Export Format", APP, &[]), CommandSpec::new(CommandId::ExportSend, "Export Send", APP, &[]), @@ -238,6 +242,9 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ CommandSpec::new(CommandId::SwitchToTab9, "Switch to Tab 9", APP, &["alt-9"]), CommandSpec::new(CommandId::SwitchToTab10, "Switch to Tab 10", APP, &["alt-0"]), CommandSpec::new(CommandId::ScrollToParagraph, "Scroll to Paragraph", APP, &[]), + CommandSpec::new(CommandId::FlowAddSiblingAbove, "Flow: Add Sibling Above", APP, &["alt-enter"]), + CommandSpec::new(CommandId::FlowDeleteSelected, "Flow: Delete Selected", APP, &["cmd-delete"]), + CommandSpec::new(CommandId::FlowStrike, "Flow: Strike", APP, &["cmd-shift-x", "ctrl-shift-x"]), ]; #[hotpath::measure] @@ -293,3 +300,8 @@ pub fn label_for(id: CommandId) -> &'static str { .map(|spec| spec.label) .unwrap_or("Unknown Command") } + +#[hotpath::measure] +pub fn context_for(id: CommandId) -> Option<&'static str> { + command_spec(id).and_then(|spec| spec.context) +} diff --git a/crates/flowstate/src/commands/keymap.rs b/crates/flowstate/src/commands/keymap.rs index 4e69f6ea..021c5377 100644 --- a/crates/flowstate/src/commands/keymap.rs +++ b/crates/flowstate/src/commands/keymap.rs @@ -88,7 +88,7 @@ fn keybinding_for_entry(entry: &KeymapEntry) -> Option { } #[hotpath::measure] -fn action_for_command(command: CommandId) -> Option> { +pub(crate) fn action_for_command(command: CommandId) -> Option> { let action: Box = match command { CommandId::MoveLeft => Box::new(MoveLeft), CommandId::MoveRight => Box::new(MoveRight), @@ -181,6 +181,7 @@ fn action_for_command(command: CommandId) -> Option> { | CommandId::SwitchToTab9 | CommandId::SwitchToTab10 | CommandId::ScrollToParagraph => return None, + CommandId::FlowAddSiblingAbove | CommandId::FlowDeleteSelected | CommandId::FlowStrike => return None, }; Some(action) } diff --git a/crates/flowstate/src/flow/cell_theme.rs b/crates/flowstate/src/flow/cell_theme.rs new file mode 100644 index 00000000..54b33bcc --- /dev/null +++ b/crates/flowstate/src/flow/cell_theme.rs @@ -0,0 +1,196 @@ +use flowstate_document::{DocumentProjection, DocumentTheme}; +use gpui::{Hsla, transparent_black}; +use palette::{FromColor as _, LinSrgb, Oklch, Srgb}; + +pub(super) fn apply_flow_cell_theme( + document: &mut DocumentProjection, + client_theme: &DocumentTheme, + foreground: Hsla, + background: Hsla, + zoom: f32, +) { + document.theme = client_theme.clone(); + document.theme.zoom_factor *= zoom; + scale_flow_layout_metrics(&mut document.theme, zoom); + let source_default = document.theme.default_text_color; + document.theme.default_text_color = foreground; + document.theme.document_background_color = transparent_black(); + document.theme.pageless_inset_x = gpui::px(0.0); + document.theme.pageless_inset_top = gpui::px(0.0); + document.theme.pageless_inset_bottom = gpui::px(0.0); + document.theme.invisibility_visible_paragraph_styles.clear(); + for slot in [3, 4, 6] { + document + .theme + .invisibility_visible_paragraph_styles + .insert(slot); + } + document.theme.invisibility_visible_semantic_styles.clear(); + document + .theme + .invisibility_visible_semantic_styles + .insert(1); + document.theme.invisibility_visible_highlight_styles.clear(); + document.theme.default_highlight_color = + transform_color(document.theme.default_highlight_color, source_default, foreground, background, false); + + for style in document.theme.custom_paragraph_styles.values_mut() { + style.color = transform_color(style.color, source_default, foreground, background, true); + } + for style in document.theme.custom_semantic_styles.values_mut() { + if let Some(color) = style.color.as_mut() { + *color = transform_color(*color, source_default, foreground, background, true); + } + } + for style in document.theme.custom_highlight_styles.values_mut() { + style.color = transform_color(style.color, source_default, foreground, background, false); + } +} + +fn scale_flow_layout_metrics(theme: &mut DocumentTheme, zoom: f32) { + theme.paragraph_after *= zoom; + for style in theme.custom_paragraph_styles.values_mut() { + style.spacing_before *= zoom; + style.spacing_after *= zoom; + if let Some(border) = style.border.as_mut() { + border.width *= zoom; + border.space_x *= zoom; + border.space_y *= zoom; + } + } +} + +fn transform_color(source: Hsla, source_default: Hsla, target_default: Hsla, background: Hsla, enforce_text_contrast: bool) -> Hsla { + let source = to_oklch(source); + let source_default = to_oklch(source_default); + let target_default = to_oklch(target_default); + let transformed = Oklch::new( + (target_default.0.l + source.0.l - source_default.0.l).clamp(0.0, 1.0), + (target_default.0.chroma + source.0.chroma - source_default.0.chroma).max(0.0), + target_default.0.hue + (source.0.hue - source_default.0.hue), + ); + let transformed = from_oklch(transformed, source.1); + if enforce_text_contrast { + ensure_contrast(transformed, background, 3.0) + } else { + transformed + } +} + +fn to_oklch(color: Hsla) -> (Oklch, f32) { + let rgb = color.to_rgb(); + let linear: LinSrgb = Srgb::new(rgb.r, rgb.g, rgb.b).into_linear(); + (Oklch::from_color(linear), color.a) +} + +fn from_oklch(color: Oklch, alpha: f32) -> Hsla { + let rgb: Srgb = LinSrgb::from_color(color).into_encoding(); + Hsla::from(gpui::Rgba { + r: rgb.red.clamp(0.0, 1.0), + g: rgb.green.clamp(0.0, 1.0), + b: rgb.blue.clamp(0.0, 1.0), + a: alpha, + }) +} + +fn contrast_ratio(foreground: Hsla, background: Hsla) -> f32 { + let luminance = |color: Hsla| { + let rgb = color.to_rgb(); + let linear = |channel: f32| { + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(rgb.r) + 0.7152 * linear(rgb.g) + 0.0722 * linear(rgb.b) + }; + let foreground = luminance(foreground); + let background = luminance(background); + (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05) +} + +fn ensure_contrast(color: Hsla, background: Hsla, minimum_ratio: f32) -> Hsla { + if color.a <= 0.5 || contrast_ratio(color, background) >= minimum_ratio { + return color; + } + let mut dark = color; + dark.l = 0.0; + let mut light = color; + light.l = 1.0; + let target_lightness = if contrast_ratio(light, background) >= contrast_ratio(dark, background) { + 1.0 + } else { + 0.0 + }; + let mut lower = 0.0; + let mut upper = 1.0; + for _ in 0..16 { + let amount = (lower + upper) / 2.0; + let mut candidate = color; + candidate.l = color.l + (target_lightness - color.l) * amount; + if contrast_ratio(candidate, background) >= minimum_ratio { + upper = amount; + } else { + lower = amount; + } + } + let mut adjusted = color; + adjusted.l = color.l + (target_lightness - color.l) * upper; + adjusted +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{px, rgba}; + + #[test] + fn flow_layout_metrics_scale_with_board_zoom() { + let mut theme = DocumentTheme { + paragraph_after: px(12.0), + ..DocumentTheme::default() + }; + + scale_flow_layout_metrics(&mut theme, 0.25); + + assert_eq!(theme.paragraph_after, px(3.0)); + } + + #[test] + fn transformed_style_colors_preserve_alpha() { + let source = Hsla::from(rgba(0x3366_994d)); + let transformed = transform_color( + source, + Hsla::from(rgba(0x0000_00ff)), + Hsla::from(rgba(0xeeee_eeff)), + Hsla::from(rgba(0x1111_11ff)), + false, + ); + assert!((transformed.a - source.a).abs() < f32::EPSILON); + } + + #[test] + fn transformed_opaque_text_remains_readable() { + let background = Hsla::from(rgba(0xf8f8_f8ff)); + let transformed = transform_color( + Hsla::from(rgba(0x5555_55ff)), + Hsla::from(rgba(0x0000_00ff)), + Hsla::from(rgba(0xf0f0_f0ff)), + background, + true, + ); + assert!(contrast_ratio(transformed, background) >= 3.0); + } + + #[test] + fn transformed_style_hues_remain_distinct_with_achromatic_theme_text() { + let source_default = Hsla::from(rgba(0x0000_00ff)); + let target_default = Hsla::from(rgba(0xffff_ffff)); + let background = Hsla::from(rgba(0x1111_11ff)); + let one = transform_color(Hsla::from(rgba(0x2563_ebff)), source_default, target_default, background, true).to_rgb(); + let two = transform_color(Hsla::from(rgba(0xdc26_26ff)), source_default, target_default, background, true).to_rgb(); + let distance = (one.r - two.r).abs() + (one.g - two.g).abs() + (one.b - two.b).abs(); + assert!(distance > 0.25); + } +} diff --git a/crates/flowstate/src/flow/editor.rs b/crates/flowstate/src/flow/editor.rs index f2543520..70f304c7 100644 --- a/crates/flowstate/src/flow/editor.rs +++ b/crates/flowstate/src/flow/editor.rs @@ -1,1517 +1,1578 @@ -use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, - path::PathBuf, -}; +use std::collections::HashSet; +use std::path::PathBuf; + +use std::sync::Arc; +use flowstate_collab::flow::{FlowDocHandle, FlowStreamItem, FlowUndoMeta}; +use flowstate_flow::intents::{CellPlacement, CellSeed}; use flowstate_flow::{ - Action, BoxNode, CommandResult, DebateStyleFlow, DebateStyleKey, FlowDocument, FormatKind, HistoryHolder, Node, NodeId, NodeValue, ROOT_ID, - add_new_box_actions, add_new_empty_actions, add_new_extension_actions, add_new_flow_actions, all_debate_style_templates, - debate_style_templates, delete_node_actions, get_json, load_flow_document_or_new, move_node_actions, new_box_id, save_flow_document, - toggle_box_format_actions, + AnnotationOriginator, BoardPoint, CellId, FlowBoardProjection, FlowDropIntent, FlowIntent, RelativePosition, SheetId, board_ops, }; use gpui::{ - App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, - ParentElement, Render, Subscription, Task, Window, div, prelude::*, px, + AnyElement, App, Bounds, Context, DragMoveEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, KeyUpEvent, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Render, ScrollHandle, ScrollWheelEvent, SharedString, Subscription, Task, Window, + canvas, div, point, prelude::*, px, rgba, +}; +use gpui_component::ActiveTheme as _; +use gpui_component::PixelsExt as _; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::scroll::{Scrollbar, ScrollbarShow}; +use gpui_component::{Icon, IconName, Sizable as _}; + +use crate::{ + app_settings::load_document_theme, + flow::{cell_theme::apply_flow_cell_theme, flow_side_palette}, + rich_text_element::{RichTextDocumentElement, RichTextEditor}, }; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::scroll::ScrollableElement; -use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable, h_flex, v_flex}; -use rustc_hash::{FxHashMap, FxHashSet}; -use crate::rich_text_element::ToolkitTextDrag; +mod annotation; +mod cell_editing; +mod connector; +mod drag_drop; +mod layout; +mod telemetry; +mod zoom; -const COLUMN_WIDTH: f32 = 172.0; +use annotation::paint_stroke; +use connector::paint_connector_family; +use drag_drop::{DropEdge, FlowCellDrag, FlowCellDragPreview}; +use layout::{CellMeasurement, sheet_cell_layout}; +use zoom::grid_dot_metrics; -pub struct FlowEditor { - document: FlowDocument, - path: Option, - dirty: bool, - history: HistoryHolder, - focus_handle: FocusHandle, - selected_style: DebateStyleKey, - ld_toc_circuit: bool, - switch_speakers: bool, - selected_flow_id: Option, - focus_id: Option, - last_focus_ids: FxHashMap, - folded: FxHashSet, - box_inputs: FxHashMap>, - input_subscriptions: Vec, - syncing_inputs: FxHashSet, - pending_edit: Option, +#[derive(Clone, Debug)] +pub enum FlowEditorEvent { + Changed, + ActiveCellChanged(Option), + ActiveSheetChanged(Option), + /// The caret moved inside the active cell's editor (presence refresh only — + /// no document change). + CellSelectionChanged, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct FlowOutlineItem { - pub id: NodeId, - pub label: String, - pub index: usize, - pub selected: bool, - pub invert: bool, +/// One remote peer's board focus, resolved for rendering: which sheet they +/// are on, which cell they focus (outline + name chip), whether they are +/// editing inside it, and — because cells are real CRDT text — their exact +/// caret as encoded Loro cursors (forwarded into the open cell editor). +#[derive(Clone, Debug, PartialEq)] +pub struct FlowExternalPresence { + pub name: String, + pub color_rgb: u32, + pub sheet: Option, + pub cell: Option, + pub editing: bool, + pub caret: Option, } -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FlowCommandState { - pub has_flow: bool, - pub has_selected_box: bool, - pub can_format: bool, - pub can_fold: bool, - pub selected_bold: bool, - pub selected_crossed: bool, - pub selected_folded: bool, - pub can_undo: bool, - pub can_redo: bool, +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AnnotationTool { + #[default] + None, + Marker, + Eraser, } -#[derive(Clone, Debug)] -struct PendingEdit { - id: NodeId, - owner: NodeId, - before_focus: Option, - after_focus: Option, - old_value: NodeValue, +struct PanDragState { + pointer_anchor: gpui::Point, + scroll_anchor: gpui::Point, + pending_position: gpui::Point, + frame_scheduled: bool, } -#[hotpath::measure_all] -impl FlowEditor { - pub fn new_with_path(document: FlowDocument, path: Option, _window: &mut Window, cx: &mut Context) -> Self { - let selected_flow_id = document.flow_ids().first().cloned(); - let focus_id = selected_flow_id.clone(); +pub struct FlowEditor { + /// The ONE local write path (invariant 5): identical handle for solo and + /// collaborative tabs; the board copy advances only via the ordered stream. + handle: Arc, + board: FlowBoardProjection, + /// Render cache for INACTIVE cells' rich-text previews (materialized on + /// demand, dropped on any board change / remote sync). + cell_documents: std::collections::HashMap, + path: Option, + /// Debounced `.fl0` recovery file for pathless joined collaboration tabs. + recovery_path: Option, + recovery_write_pending: bool, + /// Remote peers' board focus (presence rendering, spec Part C). + external_presences: Vec, + dirty: bool, + active_sheet: Option, + active_cell: Option, + collapsed_outline_items: HashSet, + annotation_tool: AnnotationTool, + hidden_annotation_sheets: HashSet, + hidden_annotation_originators: HashSet, + local_annotation_originator: AnnotationOriginator, + drawing_points: Vec, + cell_editors: std::collections::HashMap>, + cell_editor_themes: std::collections::HashMap, + cell_editor_subscriptions: std::collections::HashMap, + pending_cell_drop: Option, + dragging_cell: Option, + drag_autoscroll: Option>, + drag_autoscroll_scheduled: bool, + drag_log: Option, + drag_log_counter: u64, + cell_bounds: std::collections::HashMap>, + cell_measurements: std::collections::HashMap, + board_scroll: ScrollHandle, + board_zoom: f32, + camera_center: Option, + camera_apply_pending: bool, + viewport_origin: BoardPoint, + space_pan_armed: bool, + pan_drag: Option, + focus_handle: FocusHandle, +} +impl FlowEditor { + pub fn new_with_path(handle: Arc, path: Option, _window: &mut Window, cx: &mut Context) -> Self { + let board = handle.board_projection().unwrap_or(FlowBoardProjection { + format: flowstate_flow::FlowFormat::policy_debate(), + sheets: Vec::new(), + }); + let active_sheet = board.sheets.first().map(|sheet| sheet.id); + let collapsed_outline_items = board + .sheets + .iter() + .flat_map(|sheet| std::iter::once(sheet.id).chain(sheet.cells.iter().map(|cell| cell.id))) + .collect(); Self { - document, + handle, + board, + cell_documents: std::collections::HashMap::new(), path, + recovery_path: None, + recovery_write_pending: false, + external_presences: Vec::new(), dirty: false, - history: HistoryHolder::new(), + active_sheet, + active_cell: None, + collapsed_outline_items, + annotation_tool: AnnotationTool::None, + hidden_annotation_sheets: HashSet::new(), + hidden_annotation_originators: HashSet::new(), + local_annotation_originator: AnnotationOriginator("local".into()), + drawing_points: Vec::new(), + cell_editors: std::collections::HashMap::new(), + cell_editor_themes: std::collections::HashMap::new(), + cell_editor_subscriptions: std::collections::HashMap::new(), + pending_cell_drop: None, + dragging_cell: None, + drag_autoscroll: None, + drag_autoscroll_scheduled: false, + drag_log: None, + drag_log_counter: 0, + cell_bounds: std::collections::HashMap::new(), + cell_measurements: std::collections::HashMap::new(), + board_scroll: ScrollHandle::new(), + board_zoom: 1.0, + camera_center: None, + camera_apply_pending: false, + viewport_origin: BoardPoint::default(), + space_pan_armed: false, + pan_drag: None, focus_handle: cx.focus_handle(), - selected_style: DebateStyleKey::Policy, - ld_toc_circuit: false, - switch_speakers: false, - selected_flow_id, - focus_id, - last_focus_ids: FxHashMap::default(), - folded: FxHashSet::default(), - box_inputs: FxHashMap::default(), - input_subscriptions: Vec::new(), - syncing_inputs: FxHashSet::default(), - pending_edit: None, } } pub fn blank(window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_path(FlowDocument::new(), None, window, cx) + let (handle, _gate) = FlowDocHandle::new_document(&flowstate_flow::FlowFormat::policy_debate()).expect("fresh flow document"); + Self::new_with_path(handle, None, window, cx) } - pub fn load_or_new(path: PathBuf, window: &mut Window, cx: &mut Context) -> Self { - Self::new_with_path(load_flow_document_or_new(&path), Some(path), window, cx) + pub fn handle(&self) -> &Arc { + &self.handle } - pub fn document_path(&self) -> Option<&PathBuf> { - self.path.as_ref() + pub fn board(&self) -> &FlowBoardProjection { + &self.board } - pub fn set_path(&mut self, path: PathBuf, cx: &mut Context) { - self.path = Some(path); + /// Drain the ordered board stream and adopt the latest Replace — the ONE + /// way this editor's board copy advances. Local intents call it right after + /// `apply`; the collaboration session calls it as a pump when remote + /// batches land; solo tabs need no pump. + pub fn sync_board_from_handle(&mut self, cx: &mut Context) { + let Ok(items) = self.handle.drain_board_stream() else { + return; + }; + let Some(FlowStreamItem::Board(board)) = items.into_iter().next_back() else { + return; + }; + self.board = *board; + self.cell_documents.clear(); + self.prune_dead_cell_state(cx); + if let Some(active) = self.active_sheet + && self.board.sheet(active).is_none() + { + self.active_sheet = self.board.sheets.first().map(|sheet| sheet.id); + cx.emit(FlowEditorEvent::ActiveSheetChanged(self.active_sheet)); + } + if let Some(active) = self.active_cell + && self.board.cell(active).is_none() + { + self.active_cell = None; + cx.emit(FlowEditorEvent::ActiveCellChanged(None)); + } cx.notify(); } - pub fn has_unsaved_changes(&self) -> bool { - self.dirty || self.pending_edit.is_some() - } - - pub fn save(&mut self, cx: &mut Context) -> Task> { - self.resolve_pending_edit(cx); - let Some(path) = self.path.clone() else { - return cx - .background_executor() - .spawn(async { Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "flow has no save path")) }); - }; - self.save_to_path(path, cx) + /// Apply one flow intent through the handle and integrate the outcome. + fn apply_intent(&mut self, intent: FlowIntent, cx: &mut Context) -> bool { + match self.handle.apply(intent) { + Ok(outcome) => { + self.sync_board_from_handle(cx); + if outcome.changed { + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); + } + outcome.changed + }, + Err(rejection) => { + tracing::warn!(%rejection, "flow intent rejected"); + false + }, + } } - pub fn save_as(&mut self, path: PathBuf, cx: &mut Context) -> Task> { - self.resolve_pending_edit(cx); - self.path = Some(path.clone()); - self.save_to_path(path, cx) + /// Update the undo context riding future commits (focus restoration). + fn refresh_undo_context(&self) { + self.handle.set_undo_context(FlowUndoMeta { + active_sheet: self.active_sheet, + focused_cell: self.active_cell, + head_cursor: None, + anchor_cursor: None, + }); } - fn save_to_path(&mut self, path: PathBuf, cx: &mut Context) -> Task> { - let saved_document = self.document.clone(); - cx.spawn(async move |editor, cx| { - let write_result = cx - .background_executor() - .spawn({ - let saved_document = saved_document.clone(); - async move { save_flow_document(&path, &saved_document).map_err(std::io::Error::other) } - }) - .await; - match write_result { - Ok(()) => { - let _ = editor.update(cx, |editor, cx| { - if editor.pending_edit.is_none() && editor.document == saved_document { - editor.dirty = false; - } - cx.notify(); - }); - Ok(()) - }, - Err(error) => Err(error), - } - }) + pub fn active_sheet(&self) -> Option { + self.active_sheet } - pub fn discard_recovery_file(&mut self) {} - - pub fn json_snapshot(&mut self, cx: &mut Context) -> Option { - self.resolve_pending_edit(cx); - get_json(&self.document).ok() + pub fn active_cell(&self) -> Option { + self.active_cell } - pub fn resolve_pending(&mut self, cx: &mut Context) { - self.resolve_pending_edit(cx); + pub fn focus_active_cell(&mut self, window: &mut Window, cx: &mut Context) { + cx.on_next_frame(window, |flow, window, cx| { + let Some(editor) = flow + .active_cell + .and_then(|cell| flow.cell_editors.get(&cell)) + .cloned() + else { + return; + }; + editor.update(cx, |editor, cx| editor.move_document_start(cx)); + editor.read(cx).focus_handle(cx).focus(window); + }); } - pub fn selected_flow_id(&self) -> Option<&str> { - self.selected_flow_id.as_deref() + pub fn annotation_tool(&self) -> AnnotationTool { + self.annotation_tool } - pub fn selected_flow_title(&self) -> String { + pub fn annotations_visible(&self) -> bool { self - .selected_flow_id - .as_ref() - .and_then(|id| self.document.flow(id)) - .map(|flow| flow.content.clone()) - .unwrap_or_default() - } - - pub fn set_selected_flow_title(&mut self, value: String, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { - return; - }; - self.on_title_change(&flow_id, value, cx); + .active_sheet + .is_some_and(|sheet| !self.hidden_annotation_sheets.contains(&sheet)) } - pub fn outline_items(&self) -> Vec { - self - .document - .flow_ids() - .iter() - .enumerate() - .map(|(index, id)| { - let flow = self.document.flow(id); - let label = flow - .map(|flow| { - if flow.content.is_empty() { - format!("Flow {}", index + 1) - } else { - flow.content.clone() - } - }) - .unwrap_or_else(|| "Missing flow".to_string()); - FlowOutlineItem { - id: id.clone(), - label, - index, - selected: self.selected_flow_id.as_deref() == Some(id.as_str()), - invert: flow.is_some_and(|flow| flow.invert), - } - }) - .collect() + pub fn document_path(&self) -> Option<&PathBuf> { + self.path.as_ref() } - pub fn command_state(&self) -> FlowCommandState { - let selected_box = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)); - let can_format = selected_box - .as_ref() - .and_then(|id| self.document.box_node(id)) - .is_some_and(|box_node| !box_node.is_extension); - let can_fold = selected_box - .as_ref() - .and_then(|id| self.document.node(id)) - .is_some_and(|node| !node.children.is_empty()); - let selected_box_node = selected_box - .as_ref() - .and_then(|id| self.document.box_node(id)); - let owner = self - .selected_flow_id - .clone() - .unwrap_or_else(|| ROOT_ID.to_string()); - FlowCommandState { - has_flow: self.selected_flow_id.is_some(), - has_selected_box: selected_box.is_some(), - can_format, - can_fold, - selected_bold: selected_box_node.is_some_and(|box_node| box_node.bold), - selected_crossed: selected_box_node.is_some_and(|box_node| box_node.crossed), - selected_folded: selected_box - .as_ref() - .is_some_and(|id| self.folded.contains(id)), - can_undo: self.history.can_undo(&owner), - can_redo: self.history.can_redo(&owner), - } + pub fn set_path(&mut self, path: PathBuf, cx: &mut Context) { + self.path = Some(path); + cx.notify(); } - pub fn selected_style(&self) -> DebateStyleKey { - self.selected_style + pub fn has_unsaved_changes(&self) -> bool { + self.dirty } - pub fn selected_style_label(&self) -> &'static str { - all_debate_style_templates() - .into_iter() - .find(|template| template.key == self.selected_style) - .map(|template| template.label) - .unwrap_or("Policy") + pub fn activate_sheet(&mut self, sheet_id: SheetId, cx: &mut Context) { + if self.board.sheet(sheet_id).is_some() { + self.active_sheet = Some(sheet_id); + self.active_cell = None; + self.refresh_undo_context(); + cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(sheet_id))); + cx.notify(); + } } - #[hotpath::measure] - pub fn set_selected_style(&mut self, style: DebateStyleKey, cx: &mut Context) { - if self.selected_style != style { - self.selected_style = style; - self.switch_speakers = false; + pub fn activate_cell(&mut self, cell_id: CellId, cx: &mut Context) { + let sheet_id = self.board.cell(cell_id).map(|(sheet, _)| sheet.id); + if let Some(sheet_id) = sheet_id { + self.apply_intent(FlowIntent::EnsureCellEditable { sheet_id, cell_id }, cx); + self.ensure_cell_editor(cell_id, cx); + self.active_sheet = Some(sheet_id); + self.active_cell = Some(cell_id); + self.refresh_undo_context(); + self.scroll_cell_into_view(cell_id); + cx.emit(FlowEditorEvent::ActiveCellChanged(Some(cell_id))); cx.notify(); } } - #[hotpath::measure] - pub fn ld_toc_circuit(&self) -> bool { - self.ld_toc_circuit + pub fn outline_item_expanded(&self, id: uuid::Uuid) -> bool { + !self.collapsed_outline_items.contains(&id) } - #[hotpath::measure] - pub fn toggle_ld_toc_circuit(&mut self, cx: &mut Context) { - self.ld_toc_circuit = !self.ld_toc_circuit; - self.switch_speakers = false; + pub fn toggle_outline_item(&mut self, id: uuid::Uuid, cx: &mut Context) { + if !self.collapsed_outline_items.remove(&id) { + self.collapsed_outline_items.insert(id); + } cx.notify(); } - #[hotpath::measure] - pub fn switch_speakers(&self) -> bool { - self.switch_speakers + pub fn add_response(&mut self, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { + return; + }; + self.add_cell_with_placement(sheet, CellPlacement::ResponseTo { parent: cell }, cx); } - #[hotpath::measure] - pub fn toggle_switch_speakers(&mut self, cx: &mut Context) { - if self.has_switchable_templates() { - self.switch_speakers = !self.switch_speakers; - cx.notify(); + pub fn add_first_response_to(&mut self, cell: CellId, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + self.add_cell_with_placement(sheet, CellPlacement::FirstResponseTo { parent: cell }, cx); + } + + /// The one `AddCell` path behind every `add_*` entry point. + fn add_cell_with_placement(&mut self, sheet_id: SheetId, placement: CellPlacement, cx: &mut Context) { + let cell_id = uuid::Uuid::new_v4(); + if self.apply_intent( + FlowIntent::AddCell { + sheet_id, + cell_id, + placement, + seed: CellSeed::Empty, + }, + cx, + ) { + self.collapsed_outline_items.insert(cell_id); + self.ensure_cell_editor(cell_id, cx); + self.changed(Some(cell_id), cx); } } - #[hotpath::measure] - pub fn templates(&self) -> Vec { - let mut templates = debate_style_templates(self.selected_style, self.ld_toc_circuit); - if self.switch_speakers { - let mut flipped = Vec::with_capacity(templates.len()); - for pair in templates.chunks(2) { - if pair.len() == 2 { - flipped.push(pair[1].clone()); - flipped.push(pair[0].clone()); - } else if let Some(flow) = pair.first() { - flipped.push(flow.clone()); - } - } - templates = flipped; - } - templates + pub fn active_cell_is_struck(&self) -> bool { + self + .active_cell + .and_then(|cell| self.board.cell(cell)) + .is_some_and(|(_, cell)| cell.summary.struck) } - #[hotpath::measure] - pub fn has_switchable_templates(&self) -> bool { - debate_style_templates(self.selected_style, self.ld_toc_circuit) - .iter() - .any(|template| template.columns_switch.is_some()) + pub fn add_first_argument(&mut self, cx: &mut Context) { + self.add_orphan_at_column_top(0, cx); } - #[hotpath::measure] - pub fn select_flow(&mut self, flow_id: NodeId, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - self.selected_flow_id = Some(flow_id.clone()); - let focus = self - .last_focus_ids - .get(&flow_id) - .cloned() - .unwrap_or(flow_id); - self.set_focus(Some(focus), window, cx); + pub fn add_orphan_at_column_top(&mut self, column_index: usize, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + self.add_cell_with_placement(sheet, CellPlacement::ColumnTop { column_index }, cx); } - #[hotpath::measure] - fn set_focus(&mut self, focus_id: Option, window: &mut Window, cx: &mut Context) { - if self.focus_id != focus_id { - self.resolve_pending_edit(cx); - } - self.focus_id = focus_id.clone(); - if let Some(focus_id) = focus_id { - if let Some(flow_id) = self.document.parent_flow_id(&focus_id) { - self.last_focus_ids.insert(flow_id, focus_id.clone()); - } - self.focus_input(&focus_id, window, cx); - } - cx.notify(); + pub fn create_sheet(&mut self, cx: &mut Context) { + self.create_sheet_of_type(0, cx); } - #[hotpath::measure] - fn focus_input(&self, id: &str, window: &mut Window, cx: &mut Context) { - if let Some(input) = self.box_inputs.get(id) { - input.update(cx, |input, cx| input.focus(window, cx)); + pub fn create_sheet_of_type(&mut self, sheet_type_index: usize, cx: &mut Context) { + let Some(sheet_type) = self.board.format.sheet_types.get(sheet_type_index) else { + return; + }; + let sheet_type_id = sheet_type.id; + let name = format!("Sheet {}", self.board.sheets.len() + 1); + let sheet_id = uuid::Uuid::new_v4(); + if self.apply_intent( + FlowIntent::CreateSheet { + sheet_id, + name, + sheet_type_id, + }, + cx, + ) { + self.collapsed_outline_items.insert(sheet_id); + self.active_sheet = Some(sheet_id); + self.active_cell = None; + self.refresh_undo_context(); + cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(sheet_id))); + cx.notify(); } } - #[hotpath::measure] - fn perform_command(&mut self, command: CommandResult, before_focus: Option, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - let after_focus = command.focus.clone().or(before_focus.clone()); - let inverse = self.document.apply_action_bundle(command.actions); - self - .history - .add(command.owner, inverse, before_focus, after_focus.clone()); - self.dirty = true; - if let Some(focus) = after_focus { - if self.document.node(&focus).is_some() { - self.set_focus(Some(focus), window, cx); - } else { - cx.notify(); - } - } else { - cx.notify(); + pub fn rename_active_sheet(&mut self, name: impl Into, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if self.apply_intent( + FlowIntent::RenameSheet { + sheet_id: sheet, + name: name.into(), + }, + cx, + ) { + self.changed(self.active_cell, cx); } } - #[hotpath::measure] - pub fn add_flow(&mut self, template: DebateStyleFlow, window: &mut Window, cx: &mut Context) { - let command = add_new_flow_actions(self.document.flow_ids().len(), &template, self.switch_speakers); - let new_flow_id = command.focus.clone(); - self.perform_command(command, self.focus_id.clone(), window, cx); - if let Some(flow_id) = new_flow_id { - self.selected_flow_id = Some(flow_id.clone()); - self.set_focus(Some(flow_id), window, cx); + pub fn delete_active_sheet(&mut self, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if self.apply_intent(FlowIntent::DeleteSheet { sheet_id: sheet }, cx) { + self.collapsed_outline_items.remove(&sheet); + self.active_sheet = self.board.sheets.first().map(|sheet| sheet.id); + self.active_cell = None; + self.refresh_undo_context(); + cx.emit(FlowEditorEvent::ActiveSheetChanged(self.active_sheet)); + cx.emit(FlowEditorEvent::ActiveCellChanged(None)); + cx.notify(); } } - #[hotpath::measure] - pub fn delete_selected_flow(&mut self, window: &mut Window, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { + pub fn move_active_sheet(&mut self, direction: isize, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { return; }; - let old_index = self - .document - .flow_ids() + let Some(index) = self + .board + .sheets .iter() - .position(|id| id == &flow_id) - .unwrap_or(0); - let Some(command) = delete_node_actions(&self.document, flow_id) else { + .position(|candidate| candidate.id == sheet) + else { return; }; - self.perform_command(command, self.focus_id.clone(), window, cx); - let next_flow = if self.document.flow_ids().is_empty() { - None - } else if old_index == 0 { - self.document.flow_ids().first().cloned() - } else { - self.document.flow_ids().get(old_index - 1).cloned() - }; - self.selected_flow_id = next_flow.clone(); - self.set_focus(next_flow, window, cx); + let target = index + .saturating_add_signed(direction) + .min(self.board.sheets.len().saturating_sub(1)); + if target != index + && self.apply_intent( + FlowIntent::MoveSheet { + sheet_id: sheet, + target_index: target, + }, + cx, + ) + { + self.changed(self.active_cell, cx); + } } - #[hotpath::measure] - pub fn move_flow_to_index(&mut self, flow_id: NodeId, new_index: usize, window: &mut Window, cx: &mut Context) { - let Some(current_index) = self - .document - .flow_ids() - .iter() - .position(|id| id == &flow_id) - else { - return; + fn set_cell_bounds(&mut self, cell_id: CellId, bounds: Bounds, cx: &mut Context) { + self.cell_bounds.insert(cell_id, bounds); + let screen_height = bounds.size.height.as_f32(); + let model_height_changed = match self.cell_measurements.entry(cell_id) { + std::collections::hash_map::Entry::Occupied(mut entry) => entry.get_mut().update(screen_height, self.board_zoom), + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(CellMeasurement::new(screen_height, self.board_zoom)); + true + }, }; - let target_index = new_index.min(self.document.flow_ids().len().saturating_sub(1)); - if current_index == target_index { - self.select_flow(flow_id, window, cx); - return; + if model_height_changed { + cx.notify(); } - let Some(command) = move_node_actions(&self.document, flow_id.clone(), target_index) else { - return; - }; - self.perform_command(command, self.focus_id.clone(), window, cx); - self.selected_flow_id = Some(flow_id.clone()); - self.set_focus(Some(flow_id), window, cx); } - #[hotpath::measure] - fn add_empty_at_column(&mut self, level: usize, window: &mut Window, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { + fn scroll_cell_into_view(&mut self, cell_id: CellId) { + let Some(cell) = self.cell_bounds.get(&cell_id) else { return; }; - if let Some(command) = add_new_empty_actions(&self.document, flow_id, level) { - self.perform_command(command, self.focus_id.clone(), window, cx); + let viewport = self.board_scroll.bounds(); + let mut offset = self.board_scroll.offset(); + if cell.left() < viewport.left() { + offset.x += viewport.left() - cell.left(); + } else if cell.right() > viewport.right() { + offset.x -= cell.right() - viewport.right(); } + if cell.top() < viewport.top() { + offset.y += viewport.top() - cell.top(); + } else if cell.bottom() > viewport.bottom() { + offset.y -= cell.bottom() - viewport.bottom(); + } + self.set_user_scroll_offset(offset); } - #[hotpath::measure] - pub fn add_child_to_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self.focus_id.clone() else { - return; - }; - self.add_child(target_id, window, cx); + fn cell_text_color(&self, cell_id: CellId, cx: &App) -> gpui::Hsla { + self + .board + .cell(cell_id) + .and_then(|(sheet, cell)| { + let definition = self.board.format.sheet_type(sheet.sheet_type_id)?; + let column = definition + .columns + .iter() + .find(|column| column.id == cell.column_id)?; + Some(flow_side_palette(column.side, cx).base) + }) + .unwrap_or(cx.theme().foreground) } - #[hotpath::measure] - fn add_child(&mut self, target_id: NodeId, window: &mut Window, cx: &mut Context) { - let Some(node) = self.document.node(&target_id).cloned() else { - return; - }; - let Some(flow_id) = self.document.parent_flow_id(&target_id) else { - return; - }; - let Some(flow) = self.document.flow(&flow_id) else { - return; - }; - if node.level >= flow.columns.len() as i32 { - self.set_focus(Some(target_id), window, cx); - return; - } - let mut index = 0; - if let Some(first_child) = node.children.first() - && self - .document - .box_node(first_child) - .is_some_and(|box_node| box_node.is_extension) - { - index = 1; - } - if let Some(command) = add_new_box_actions(&self.document, target_id, index, None) { - self.perform_command(command, self.focus_id.clone(), window, cx); - } + fn begin_pan(&mut self, position: gpui::Point, cx: &mut Context) { + self.pan_drag = Some(PanDragState { + pointer_anchor: position, + scroll_anchor: self.board_scroll.offset(), + pending_position: position, + frame_scheduled: false, + }); + cx.notify(); } - #[hotpath::measure] - pub fn add_sibling_to_focus(&mut self, direction: usize, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { + fn queue_pan(&mut self, position: gpui::Point, window: &mut Window, cx: &mut Context) { + let Some(pan_drag) = self.pan_drag.as_mut() else { return; }; - let Some(node) = self.document.node(&target_id).cloned() else { + pan_drag.pending_position = position; + if pan_drag.frame_scheduled { return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let index = self - .document - .child_index(&parent_id, &target_id) - .map(|index| index + direction) - .unwrap_or(direction); - if let Some(command) = add_new_box_actions(&self.document, parent_id, index, None) { - self.perform_command(command, Some(target_id), window, cx); } + pan_drag.frame_scheduled = true; + cx.on_next_frame(window, |editor, _, cx| { + let offset = { + let Some(pan_drag) = editor.pan_drag.as_mut() else { + return; + }; + pan_drag.frame_scheduled = false; + pan_drag.scroll_anchor + (pan_drag.pending_position - pan_drag.pointer_anchor) + }; + editor.set_user_scroll_offset(offset); + cx.notify(); + }); } - pub fn insert_toolkit_text(&mut self, title: &str, text: &str, window: &mut Window, cx: &mut Context) { - let content = sanitize_input_value(text.trim().to_string()); - if content.is_empty() { - return; - } - self.resolve_pending_edit(cx); - let Some(flow_id) = self.selected_flow_id.clone() else { + fn finish_space_pan(&mut self, cx: &mut Context) { + self.pan_drag = None; + cx.notify(); + } + + pub fn add_sibling(&mut self, position: RelativePosition, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { return; }; - let parent_id = self - .focus_id - .as_ref() - .filter(|id| self.document.check_box_id(id).is_some()) - .filter(|id| { - let Some(node) = self.document.node(id) else { - return false; - }; - let Some(flow) = self.document.flow(&flow_id) else { - return false; - }; - node.level + 1 < flow.columns.len() as i32 - }) - .cloned() - .unwrap_or_else(|| flow_id.clone()); - let Some(parent) = self.document.node(&parent_id) else { + self.add_cell_with_placement(sheet, CellPlacement::Sibling { of: cell, position }, cx); + } + + pub fn active_cell_is_empty(&self) -> bool { + self + .active_cell + .and_then(|cell| self.board.cell(cell)) + .is_some_and(|(_, cell)| cell.summary.is_empty) + } + + pub fn delete_selected(&mut self, window: &mut Window, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { return; }; - let box_id = new_box_id(); - let action = Action::Add { - parent: parent_id, - id: box_id.clone(), - index: parent.children.len(), - value: NodeValue::Box(BoxNode { - content, - flow_id: flow_id.clone(), - placeholder: (!title.trim().is_empty()).then(|| title.trim().to_string()), - empty: false, - crossed: false, - bold: false, - is_extension: false, - }), - }; - self.perform_command( - CommandResult { - actions: vec![action], - owner: flow_id, - focus: Some(box_id), + let fallback = board_ops::deletion_fallback(&self.board, sheet, cell); + if self.apply_intent( + FlowIntent::DeleteCell { + sheet_id: sheet, + cell_id: cell, }, - self.focus_id.clone(), - window, cx, - ); + ) { + self.drop_cell_editor(cell); + if let Some(fallback) = fallback { + self.ensure_cell_editor(fallback, cx); + self.changed(Some(fallback), cx); + if let Some(editor) = self.cell_editors.get(&fallback) { + editor.read(cx).focus_handle(cx).focus(window); + } + self.scroll_cell_into_view(fallback); + } else { + self.changed(None, cx); + } + } } - #[hotpath::measure] - pub fn extend_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - if self - .document - .node(&target_id) - .and_then(|node| node.children.first()) - .and_then(|first| self.document.box_node(first)) - .is_some_and(|box_node| box_node.is_extension) - { - return; - } - if self - .document - .box_node(&target_id) - .is_some_and(|box_node| box_node.is_extension) - { - return; - } - let Some(flow_id) = self.document.parent_flow_id(&target_id) else { - return; - }; - let Some(flow) = self.document.flow(&flow_id) else { - return; - }; - let Some(node) = self.document.node(&target_id) else { + pub fn strike_selected(&mut self, cx: &mut Context) { + let Some((sheet, cell)) = self.active_sheet.zip(self.active_cell) else { return; }; - if node.level >= flow.columns.len() as i32 - 1 { - return; - } - if let Some(command) = add_new_extension_actions(&self.document, target_id.clone()) { - self.perform_command(command, Some(target_id), window, cx); + let struck = self + .board + .cell(cell) + .is_some_and(|(_, cell)| cell.summary.struck); + if self.apply_intent( + FlowIntent::SetCellStruck { + sheet_id: sheet, + cell_id: cell, + struck: !struck, + }, + cx, + ) { + self.changed(Some(cell), cx); } } - #[hotpath::measure] - pub fn delete_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - let Some(node) = self.document.node(&target_id).cloned() else { - return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let Some(parent) = self.document.node(&parent_id).cloned() else { - return; - }; - if parent.value == NodeValue::Root || (matches!(parent.value, NodeValue::Flow(_)) && parent.children.len() <= 1) { - self.set_focus(Some(target_id), window, cx); - return; - } - let target_index = parent - .children - .iter() - .position(|child| child == &target_id) - .unwrap_or(0); - let next_focus = if target_index > 0 { - parent.children.get(target_index - 1).cloned() - } else { - Some(parent_id.clone()).filter(|id| id != ROOT_ID) - }; - if let Some(command) = delete_node_actions(&self.document, target_id.clone()) { - self.perform_command(command, Some(target_id), window, cx); - self.set_focus(next_focus, window, cx); - } + pub fn can_undo(&self) -> bool { + self.handle.can_undo().unwrap_or(false) } - #[hotpath::measure] - fn delete_empty_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - if !self - .document - .box_node(&target_id) - .is_some_and(|box_node| box_node.content.is_empty()) + pub fn can_redo(&self) -> bool { + self.handle.can_redo().unwrap_or(false) + } + + pub fn undo(&mut self, cx: &mut Context) { + if let Ok(outcome) = self.handle.undo() + && outcome.applied { - return; - } - let Some(node) = self.document.node(&target_id).cloned() else { - return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let Some(parent) = self.document.node(&parent_id).cloned() else { - return; - }; - if parent.value == NodeValue::Root || (matches!(parent.value, NodeValue::Flow(_)) && parent.children.len() <= 1) { - return; - } - let target_index = parent - .children - .iter() - .position(|child| child == &target_id) - .unwrap_or(0); - let next_focus = if target_index > 0 { - parent.children.get(target_index - 1).cloned() - } else { - Some(parent_id.clone()).filter(|id| id != ROOT_ID) - }; - if let Some(command) = delete_node_actions(&self.document, target_id.clone()) { - self.perform_command(command, Some(target_id), window, cx); - self.set_focus(next_focus, window, cx); + self.sync_board_from_handle(cx); + self.restore_undo_focus(outcome.meta, cx); + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); } } - #[hotpath::measure] - pub fn toggle_format_focus(&mut self, format: FormatKind, window: &mut Window, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - if self - .document - .box_node(&target_id) - .is_some_and(|box_node| box_node.is_extension) + pub fn redo(&mut self, cx: &mut Context) { + if let Ok(outcome) = self.handle.redo() + && outcome.applied { - return; - } - if let Some(command) = toggle_box_format_actions(&self.document, target_id.clone(), format) { - self.perform_command(command, Some(target_id), window, cx); + self.sync_board_from_handle(cx); + self.restore_undo_focus(outcome.meta, cx); + self.dirty = true; + cx.emit(FlowEditorEvent::Changed); + cx.notify(); } } - #[hotpath::measure] - pub fn toggle_fold_focus(&mut self, cx: &mut Context) { - let Some(target_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { + /// `FlowUndoMeta` focus restoration: bring back the sheet/cell the undone + /// commit was made under (cell-text selection restores through the cell's + /// own authority). + fn restore_undo_focus(&mut self, meta: Option, cx: &mut Context) { + let Some(meta) = meta else { return; }; - if self - .document - .node(&target_id) - .is_none_or(|node| node.children.is_empty()) + if let Some(sheet) = meta.active_sheet + && self.board.sheet(sheet).is_some() + && self.active_sheet != Some(sheet) { - return; + self.active_sheet = Some(sheet); + cx.emit(FlowEditorEvent::ActiveSheetChanged(Some(sheet))); } - if !self.folded.insert(target_id.clone()) { - self.folded.remove(&target_id); + if let Some(cell) = meta.focused_cell + && self.board.cell(cell).is_some() + && self.active_cell != Some(cell) + { + self.ensure_cell_editor(cell, cx); + self.active_cell = Some(cell); + cx.emit(FlowEditorEvent::ActiveCellChanged(Some(cell))); } - cx.notify(); } - #[hotpath::measure] - pub fn undo_selected(&mut self, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - let Some(owner) = self.selected_flow_id.clone() else { - return; - }; - if let Some(focus) = self.history.undo(owner, &mut self.document) { - self.dirty = true; - self.set_focus(focus, window, cx); + /// Drop every per-cell UI artifact for cells no longer on the board, and + /// release their runtime streams. + fn prune_dead_cell_state(&mut self, cx: &mut Context) { + let live: HashSet = self + .board + .sheets + .iter() + .flat_map(|sheet| sheet.cells.iter().map(|cell| cell.id)) + .collect(); + let dead: Vec = self + .cell_editors + .keys() + .copied() + .filter(|id| !live.contains(id)) + .collect(); + for id in dead { + self.drop_cell_editor(id); } + self.cell_bounds.retain(|id, _| live.contains(id)); + self.cell_measurements.retain(|id, _| live.contains(id)); + self.cell_documents.retain(|id, _| live.contains(id)); + let _ = cx; } - #[hotpath::measure] - pub fn redo_selected(&mut self, window: &mut Window, cx: &mut Context) { - self.resolve_pending_edit(cx); - let Some(owner) = self.selected_flow_id.clone() else { - return; - }; - if let Some(focus) = self.history.redo(owner, &mut self.document) { - self.dirty = true; - self.set_focus(focus, window, cx); - } + fn drop_cell_editor(&mut self, cell: CellId) { + self.cell_editors.remove(&cell); + self.cell_editor_themes.remove(&cell); + self.cell_editor_subscriptions.remove(&cell); + let _ = self.handle.close_cell(cell); } - #[hotpath::measure] - fn begin_or_update_edit(&mut self, id: NodeId, value: NodeValue, cx: &mut Context) { - if self.syncing_inputs.contains(&id) { - return; - } - let Some(old_node) = self.document.node(&id).cloned() else { - return; - }; - if old_node.value == value { - return; - } - let owner = match &value { - NodeValue::Flow(_) => id.clone(), - NodeValue::Box(_) => self - .document - .parent_flow_id(&id) - .unwrap_or_else(|| ROOT_ID.to_string()), - NodeValue::Root => ROOT_ID.to_string(), - }; - if self - .pending_edit - .as_ref() - .is_none_or(|pending| pending.id != id) - { - self.resolve_pending_edit(cx); - self.pending_edit = Some(PendingEdit { - id: id.clone(), - owner, - before_focus: self.focus_id.clone(), - after_focus: self.focus_id.clone(), - old_value: old_node.value.clone(), - }); - } else if let Some(pending) = &mut self.pending_edit { - pending.after_focus = self.focus_id.clone(); - } - if let Some(node) = self.document.node_mut(&id) { - node.value = value; - self.dirty = true; - cx.notify(); - } - } - - #[hotpath::measure] - fn resolve_pending_edit(&mut self, cx: &mut Context) { - let Some(pending) = self.pending_edit.take() else { - return; - }; - let Some(current) = self - .document - .node(&pending.id) - .map(|node| node.value.clone()) - else { - return; - }; - if current == pending.old_value { - return; - } - self.history.add( - pending.owner, - vec![Action::Update { - id: pending.id, - new_value: pending.old_value, - }], - pending.before_focus, - pending.after_focus, - ); + fn changed(&mut self, active_cell: Option, cx: &mut Context) { + self.active_cell = active_cell; + self.dirty = true; + self.schedule_recovery_write(cx); + cx.emit(FlowEditorEvent::Changed); + cx.emit(FlowEditorEvent::ActiveCellChanged(active_cell)); cx.notify(); } - #[hotpath::measure] - fn on_title_change(&mut self, flow_id: &str, value: String, cx: &mut Context) { - let Some(flow) = self.document.flow(flow_id).cloned() else { - return; + pub fn save(&mut self, cx: &mut Context) -> Task> { + let Some(path) = self.path.clone() else { + return cx + .background_executor() + .spawn(async { Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "flow has no save path")) }); }; - let mut next = flow; - next.content = sanitize_input_value(value); - self.begin_or_update_edit(flow_id.to_string(), NodeValue::Flow(next), cx); + self.save_to_path(path, cx) } - #[hotpath::measure] - fn on_box_change(&mut self, box_id: &str, value: String, cx: &mut Context) { - let Some(box_node) = self.document.box_node(box_id).cloned() else { - return; - }; - let mut next = box_node; - next.content = sanitize_input_value(value); - self.begin_or_update_edit(box_id.to_string(), NodeValue::Box(next), cx); - } - - #[hotpath::measure] - fn focus_first_child(&mut self, flow_id: NodeId, window: &mut Window, cx: &mut Context) { - let focus = self - .document - .node(&flow_id) - .and_then(|flow| { - flow - .children - .iter() - .find(|child_id| { - self - .document - .box_node(child_id) - .is_some_and(|box_node| !box_node.empty) - }) - .cloned() - }) - .unwrap_or(flow_id); - self.set_focus(Some(focus), window, cx); + pub fn save_as(&mut self, path: PathBuf, cx: &mut Context) -> Task> { + self.path = Some(path.clone()); + self.save_to_path(path, cx) } - #[hotpath::measure] - fn ensure_box_input(&mut self, box_id: &str, window: &mut Window, cx: &mut Context) -> Entity { - if let Some(input) = self.box_inputs.get(box_id) { - return input.clone(); - } - let box_node = self.document.box_node(box_id).cloned(); - let value = box_node - .as_ref() - .map(|box_node| box_node.content.clone()) - .unwrap_or_default(); - let placeholder = box_node - .and_then(|box_node| box_node.placeholder) - .unwrap_or_else(|| "type here".to_string()); - let input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(placeholder) - .default_value(value) - }); - let id = box_id.to_string(); - let subscription = cx.subscribe_in(&input, window, move |editor, input, event: &InputEvent, window, cx| match event { - InputEvent::Change => { - let value = input.read(cx).value().to_string(); - editor.on_box_change(&id, value, cx); - }, - InputEvent::Focus => { - if editor.focus_id.as_deref() != Some(id.as_str()) { - editor.resolve_pending_edit(cx); - editor.focus_id = Some(id.clone()); - if let Some(flow_id) = editor.document.parent_flow_id(&id) { - editor.last_focus_ids.insert(flow_id, id.clone()); - } + fn save_to_path(&mut self, path: PathBuf, cx: &mut Context) -> Task> { + // Fork under the gate (brief), encode + write on the background executor + // (the I-9a long-export rule at editor scale). + let handle = self.handle.clone(); + cx.spawn(async move |editor, cx| { + let write_result = cx + .background_executor() + .spawn(async move { + let snapshot = handle.snapshot().map_err(std::io::Error::other)?; + flowstate_flow::write_fl0(&path, &snapshot).map_err(std::io::Error::other) + }) + .await; + if write_result.is_ok() { + let _ = editor.update(cx, |editor, cx| { + editor.dirty = false; cx.notify(); - } - }, - InputEvent::Blur => { - editor.resolve_pending_edit(cx); - }, - InputEvent::BackspaceEmpty | InputEvent::DeleteEmpty => { - if editor.focus_id.as_deref() == Some(id.as_str()) { - editor.delete_empty_focus(window, cx); - } - }, - InputEvent::PressEnter { secondary: false } => { - if editor.focus_id.as_deref() == Some(id.as_str()) { - editor.add_sibling_to_focus(1, window, cx); - } - }, - InputEvent::PressEnter { secondary: true } => { - if editor.focus_id.as_deref() == Some(id.as_str()) { - editor.add_child_to_focus(window, cx); - } - }, - }); - self.input_subscriptions.push(subscription); - self.box_inputs.insert(box_id.to_string(), input.clone()); - input + }); + } + write_result + }) } - #[hotpath::measure] - fn sync_input(&mut self, id: &str, input: &Entity, value: &str, window: &mut Window, cx: &mut Context) { - if input.read(cx).value().as_ref() == value { - return; + /// Pathless joined tabs write a debounced `.fl0` recovery file so a crash + /// mid-session cannot lose the shared board (lifecycle parity, spec Part C). + pub fn set_recovery_path(&mut self, path: Option, cx: &mut Context) { + self.recovery_path = path; + if self.recovery_path.is_some() { + self.schedule_recovery_write(cx); } - self.syncing_inputs.insert(id.to_string()); - input.update(cx, |input, cx| input.set_value(value.to_string(), window, cx)); - self.syncing_inputs.remove(id); } - #[hotpath::measure] - fn focused_box_input_is_focused(&self, window: &Window, cx: &App) -> bool { - self - .focus_id - .as_ref() - .and_then(|id| self.box_inputs.get(id)) - .is_some_and(|input| input.read(cx).focus_handle(cx).is_focused(window)) - } - - #[hotpath::measure] - fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; - let command_control = modifiers.platform || modifiers.control; - let mut handled = true; - - if matches!(key, "enter" | "backspace" | "delete") && self.focused_box_input_is_focused(window, cx) { + fn schedule_recovery_write(&mut self, cx: &mut Context) { + if self.recovery_write_pending { return; } + let Some(path) = self.recovery_path.clone() else { + return; + }; + self.recovery_write_pending = true; + let handle = self.handle.clone(); + cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(750)) + .await; + let write = cx + .background_executor() + .spawn(async move { + let snapshot = handle.snapshot().map_err(std::io::Error::other)?; + flowstate_flow::write_fl0(&path, &snapshot).map_err(std::io::Error::other) + }) + .await; + if let Err(error) = write { + tracing::warn!(%error, "writing flow collaboration recovery file failed"); + } + let _ = editor.update(cx, |editor, _| { + editor.recovery_write_pending = false; + }); + }) + .detach(); + } - match (command_control, modifiers.shift, modifiers.alt, key) { - (true, true, _, "z") => self.redo_selected(window, cx), - (true, false, _, "z") => self.undo_selected(window, cx), - (true, false, _, "y") => self.redo_selected(window, cx), - (true, false, _, "n") => { - if let Some(template) = self.templates().first().cloned() { - self.add_flow(template, window, cx); - } - }, - (true, true, _, "n") => { - if let Some(template) = self.templates().get(1).cloned() { - self.add_flow(template, window, cx); - } - }, - (true, false, _, "b") => self.toggle_format_focus(FormatKind::Bold, window, cx), - (true, true, _, "x") => self.toggle_format_focus(FormatKind::Crossed, window, cx), - (true, false, _, "e") => self.extend_focus(window, cx), - (true, false, _, "backspace") | (true, false, _, "delete") => self.delete_focus(window, cx), - (false, false, false, "enter") => { - if let Some(flow_id) = self - .focus_id - .clone() - .filter(|id| self.document.flow(id).is_some()) - { - self.focus_first_child(flow_id, window, cx); - } else { - self.add_sibling_to_focus(1, window, cx); - } - }, - (false, true, _, "enter") => self.add_child_to_focus(window, cx), - (false, false, true, "enter") => self.add_sibling_to_focus(0, window, cx), - (false, false, _, "tab") => self.focus_sibling(1, window, cx), - (false, true, _, "tab") => self.focus_sibling_reverse(window, cx), - (false, false, _, "left") => self.focus_parent(window, cx), - (false, false, _, "right") => self.focus_first_child_of_focus(window, cx), - (false, false, _, "up") => self.focus_adjacent(-1, window, cx), - (false, false, _, "down") => self.focus_adjacent(1, window, cx), - (false, false, _, "backspace") => { - let can_delete_empty = self - .focus_id - .as_ref() - .and_then(|id| self.document.box_node(id)) - .is_some_and(|box_node| box_node.content.is_empty()); - if can_delete_empty { - self.delete_focus(window, cx); - } else { - handled = false; - } - }, - (false, false, _, "l") if modifiers.control => self.toggle_fold_focus(cx), - _ => handled = false, + pub fn discard_recovery_file(&mut self) { + if let Some(path) = self.recovery_path.take() + && let Err(error) = std::fs::remove_file(&path) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(%error, path = %path.display(), "removing flow collaboration recovery file failed"); } + } - if handled { - window.prevent_default(); - cx.stop_propagation(); + /// Remote peers' board focus (peer-color cell outlines + name chips; peers + /// on other sheets show as colored dots on the sheet switcher). + pub fn set_external_presences(&mut self, presences: Vec, cx: &mut Context) { + if self.external_presences != presences { + self.external_presences = presences; + self.sync_external_cell_carets(cx); + cx.notify(); } } - #[hotpath::measure] - fn focus_sibling(&mut self, direction: isize, window: &mut Window, cx: &mut Context) { - let Some(focus_id) = self - .focus_id - .as_ref() - .and_then(|id| self.document.check_box_id(id)) - else { - return; - }; - let Some(node) = self.document.node(&focus_id) else { - return; - }; - let Some(parent_id) = node.parent.clone() else { - return; - }; - let Some(parent) = self.document.node(&parent_id) else { - return; - }; - let Some(index) = parent.children.iter().position(|id| id == &focus_id) else { - return; - }; - let next_index = if direction < 0 { - index.checked_sub(direction.unsigned_abs()) - } else { - Some(index + direction as usize).filter(|ix| *ix < parent.children.len()) - }; - if let Some(next_focus) = next_index.and_then(|ix| parent.children.get(ix)).cloned() { - self.set_focus(Some(next_focus), window, cx); - } else if parent_id != ROOT_ID { - self.set_focus(Some(parent_id), window, cx); + /// The local user's board focus for the presence channel: active sheet/cell, + /// whether the cell editor is open, and the exact caret encoded as Loro + /// cursors under the flow gate. + pub fn presence_focus(&self, cx: &gpui::App) -> flowstate_collab::presence::FlowPresenceFocus { + let caret = self.active_cell.and_then(|cell| { + let editor = self.cell_editors.get(&cell)?; + let selection = editor.read(cx).selection().clone(); + self.handle.presence_selection(cell, &selection) + }); + flowstate_collab::presence::FlowPresenceFocus { + sheet: self.active_sheet, + cell: self.active_cell, + editing: self + .active_cell + .is_some_and(|cell| self.cell_editors.contains_key(&cell)), + caret, } } - #[hotpath::measure] - fn focus_sibling_reverse(&mut self, window: &mut Window, cx: &mut Context) { - self.focus_sibling(-1, window, cx); + /// The open editor for a cell, if any (presence caret forwarding + tests). + pub fn cell_editor(&self, cell: CellId) -> Option> { + self.cell_editors.get(&cell).cloned() } - #[hotpath::measure] - fn focus_parent(&mut self, window: &mut Window, cx: &mut Context) { - let Some(focus_id) = self.focus_id.clone() else { - return; - }; - let Some(parent_id) = self - .document - .node(&focus_id) - .and_then(|node| node.parent.clone()) - .filter(|id| id != ROOT_ID) - else { - return; - }; - self.set_focus(Some(parent_id), window, cx); + /// The peer-color outline for a cell a remote peer focuses (render + gate + /// test hook). First matching peer wins; roster order is deterministic. + pub fn presence_for_cell(&self, cell: CellId) -> Option<&FlowExternalPresence> { + self + .external_presences + .iter() + .find(|presence| presence.cell == Some(cell)) } - #[hotpath::measure] - fn focus_first_child_of_focus(&mut self, window: &mut Window, cx: &mut Context) { - let Some(focus_id) = self.focus_id.clone() else { - return; - }; - let Some(child_id) = self - .document - .node(&focus_id) - .and_then(|node| node.children.first()) - .cloned() - else { - return; - }; - self.set_focus(Some(child_id), window, cx); + /// Peers focused on a sheet OTHER than the local active one — rendered as + /// colored dots on that sheet's switcher row. + pub fn presence_dots_for_sheet(&self, sheet: SheetId) -> Vec { + self + .external_presences + .iter() + .filter(|presence| presence.sheet == Some(sheet)) + .map(|presence| presence.color_rgb) + .collect() } - #[hotpath::measure] - fn focus_adjacent(&mut self, direction: isize, window: &mut Window, cx: &mut Context) { - let Some(flow_id) = self.selected_flow_id.clone() else { - return; - }; - let mut ids = Vec::new(); - self.collect_visible_boxes(&flow_id, &mut ids); - let Some(focus_id) = self.focus_id.clone() else { - return; - }; - let Some(index) = ids.iter().position(|id| id == &focus_id) else { - return; - }; - let next = if direction < 0 { - index - .checked_sub(direction.unsigned_abs()) - .and_then(|ix| ids.get(ix)) - .cloned() - } else { - ids.get(index + direction as usize).cloned() - }; - if let Some(next) = next { - self.set_focus(Some(next), window, cx); + /// Forward remote peers' carets into the matching OPEN cell editors via the + /// existing external-caret API (exact positions — the cursors resolve under + /// the flow gate against the cell's current text). + pub(super) fn sync_external_cell_carets(&mut self, cx: &mut Context) { + for (cell_id, editor) in &self.cell_editors { + let mut carets = Vec::new(); + let mut selections = Vec::new(); + for presence in &self.external_presences { + if presence.cell != Some(*cell_id) { + continue; + } + let Some(caret) = presence.caret.as_ref() else { + continue; + }; + let Some((head, anchor)) = self.handle.resolve_presence_selection(*cell_id, caret) else { + continue; + }; + carets.push(crate::rich_text_element::ExternalCaret { + offset: head, + visual_gravity: crate::rich_text_element::VisualGravity::Neutral, + color_rgb: presence.color_rgb, + }); + if anchor != head { + selections.push(crate::rich_text_element::ExternalSelection { + selection: crate::rich_text_element::EditorSelection::range(anchor, head), + color_rgb: presence.color_rgb, + }); + } + } + editor.update(cx, |editor, cx| { + editor.set_external_carets(carets, cx); + editor.set_external_selections(selections, cx); + }); } } - #[hotpath::measure] - fn collect_visible_boxes(&self, id: &str, ids: &mut Vec) { - let Some(node) = self.document.node(id) else { - return; - }; - if matches!(node.value, NodeValue::Box(_)) { - ids.push(id.to_string()); - } - if self.folded.contains(id) { - return; - } - for child in &node.children { - self.collect_visible_boxes(child, ids); - } + pub fn external_presences(&self) -> &[FlowExternalPresence] { + &self.external_presences } - #[hotpath::measure] - fn render_main(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(flow_id) = self.selected_flow_id.clone() else { - return self.render_empty_flow_state(cx).into_any_element(); + pub fn resolve_pending(&mut self, _cx: &mut Context) {} + + fn render_sheet(&mut self, sheet_id: SheetId, cx: &mut Context) -> AnyElement { + // Prefill the inactive-cell rich-text preview cache OUTSIDE the render + // borrow (materialize-on-miss; dropped on any board change). + let missing: Vec = self + .board + .sheet(sheet_id) + .map(|sheet| { + sheet + .cells + .iter() + .map(|cell| cell.id) + .filter(|id| !self.cell_documents.contains_key(id)) + .collect() + }) + .unwrap_or_default(); + for id in missing { + if let Ok(document) = self.handle.cell_preview(id) { + self.cell_documents.insert(id, document); + } + } + let Some(real_sheet) = self.board.sheet(sheet_id) else { + return div().child("Select a sheet").into_any_element(); + }; + // During a drag the layout stays stable (no reflow) so drop locations don't move under the pointer: + // the dragged cell holds its slot as a faded placeholder, its children stay put, and the landing is + // shown by a directional accent on the target cell. The real subtree move still happens on drop. + let sheet = real_sheet; + let drop_target = self.drag_drop_target(real_sheet); + let Some(definition) = self.board.format.sheet_type(sheet.sheet_type_id) else { + return div().child("Invalid sheet type").into_any_element(); }; - if self.document.flow(&flow_id).is_none() { - return self.render_empty_flow_state(cx).into_any_element(); + let active = self.active_cell; + let strokes: Vec<_> = if !self.hidden_annotation_sheets.contains(&sheet_id) { + sheet + .annotations + .iter() + .filter(|stroke| { + !self + .hidden_annotation_originators + .contains(&stroke.originator) + }) + .cloned() + .collect() + } else { + Vec::new() + }; + let draft = self.drawing_points.clone(); + let client_document_theme = load_document_theme(); + let zoom = self.board_zoom; + let control_size = px(20.0 * zoom); + let control_icon_size = px(12.0 * zoom); + let cell_layout = sheet_cell_layout(sheet, &self.cell_measurements, zoom); + // The drop preview marks the target with a thin, non-displacing landing bar. A full-card-height + // gap used to be inserted here, which shoved the target — and everything below it — down by a + // whole card; for a Before landing that slid the very cell you were aiming at out from under the + // pointer (making fine placement feel like chasing a moving target, and silently handing the drop + // off to the column handler at family boundaries). A slim bar keeps the pointer over its target. + // Tuple: (column index, top in layout space, bar height). + let column_gap: Option<(usize, f32, f32)> = drop_target.and_then(|(target, edge)| { + let target_cell = sheet.cells.iter().find(|cell| cell.id == target)?; + let target_layout = cell_layout.get(&target)?; + let target_column = definition + .columns + .iter() + .position(|column| column.id == target_cell.column_id)?; + // Keep well under a card's half-height (min card is 54px) so opening the bar never displaces the + // target far enough for the pointer to fall off it. + let height = 5.0 * zoom; + Some(match edge { + DropEdge::Before => (target_column, target_layout.top, height), + DropEdge::After => (target_column, target_layout.top + target_layout.height, height), + DropEdge::Child => (target_column + 1, target_layout.top, height), + }) + }); + let board_width = px((32.0 + definition.columns.len() as f32 * 280.0 + definition.columns.len().saturating_sub(1) as f32 * 16.0) * zoom); + let column_count = definition.columns.len(); + let weak_editor = cx.entity().downgrade(); + let weak_connector_editor = weak_editor.clone(); + let mut children_by_parent: std::collections::HashMap> = std::collections::HashMap::new(); + for cell in &sheet.cells { + if let Some(parent) = cell.parent_id { + children_by_parent.entry(parent).or_default().push(cell.id); + } } - v_flex() - .flex_1() - .size_full() - .overflow_hidden() - .bg(cx.theme().background) - .child(self.render_flow_board(flow_id, window, cx)) - .into_any_element() - } - - #[hotpath::measure] - fn render_empty_flow_state(&self, cx: &mut Context) -> impl IntoElement { + let connector_families = sheet + .cells + .iter() + .filter_map(|parent| { + children_by_parent + .remove(&parent.id) + .map(|children| (parent.id, children)) + }) + .collect::>(); div() - .flex_1() - .size_full() + .id("flow-columns") + .relative() + .min_w_full() + .min_h_full() + .w(board_width) .flex() - .items_center() - .justify_center() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Choose a debate style and add a flow") - } - - #[hotpath::measure] - fn render_flow_board(&mut self, flow_id: NodeId, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(flow) = self.document.flow(&flow_id).cloned() else { - return self.render_empty_flow_state(cx).into_any_element(); - }; - let width = px(COLUMN_WIDTH * flow.columns.len().max(1) as f32); - let children = self - .document - .node(&flow_id) - .map(|node| node.children.clone()) - .unwrap_or_default(); - v_flex() - .flex_1() - .overflow_scrollbar() - .p_4() - .child( - v_flex() - .w(width) - .min_w(width) - .gap_2() + .gap(px(16.0 * zoom)) + .p(px(16.0 * zoom)) + // Board-level fallback so the landing preview never freezes in a dead zone. The per-column + // handlers only act inside a column div, but the 16px inter-column flex gaps and the outer + // padding lie outside every column, so a pointer crossing between columns would otherwise stop + // updating the drop. This runs first (capture phase, parent-before-child), so a real column or + // cell handler still overrides it whenever the pointer is genuinely inside one; it only "wins" + // in the strips no column covers. + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, window, cx| { + let bounds = event.bounds; + let position = event.event.position; + if !bounds.contains(&position) { + return; + } + editor.update_drag_autoscroll(position, window, cx); + if editor.cursor_over_live_cell(position) { + return; + } + let zoom = editor.board_zoom; + let column_width = 280.0 * zoom; + let stride = (280.0 + 16.0) * zoom; + let relative = (position.x - bounds.left()).as_f32() - 16.0 * zoom; + if relative < 0.0 { + editor.update_column_drop(0, position.y, cx); + return; + } + let raw_index = (relative / stride).floor(); + let within = relative - raw_index * stride; + let index = raw_index as usize; + if within <= column_width && index < column_count { + // Inside a real column — its own handler already covers this point, so don't fight it. + return; + } + // In the gap after `index` (or the trailing padding) — snap to the nearest real column. + editor.update_column_drop(index.min(column_count.saturating_sub(1)), position.y, cx); + })) + .children(definition.columns.iter().enumerate().map(|(column_index, column)| { + let side_palette = flow_side_palette(column.side, cx); + let side_color = side_palette.base; + let can_receive_child = column_index + 1 < definition.columns.len(); + let add_editor = cx.entity().clone(); + div() + .w(px(280.0 * zoom)) + .flex_none() + .flex_col() + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, window, cx| { + // Same as the cell handler: `on_drag_move` is not hit-tested, so gate on this column's own + // bounds, and defer to the cell handler whenever the pointer is actually over a live cell. + if !event.bounds.contains(&event.event.position) { + return; + } + editor.update_drag_autoscroll(event.event.position, window, cx); + if editor.cursor_over_live_cell(event.event.position) { + return; + } + editor.update_column_drop(column_index, event.event.position.y, cx); + if let Some(intent) = editor.pending_cell_drop { + editor.log_drag_over_column(column_index, event.event.position, intent); + } + })) + .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| editor.finish_cell_drop(drag.cell_id, cx))) .child( - h_flex() - .bg(cx.theme().background) - .children(flow.columns.iter().enumerate().map(|(ix, column)| { - let colors = flow_column_colors(column, cx); - h_flex() - .w(px(COLUMN_WIDTH)) - .h(px(34.0)) - .items_center() - .justify_center() - .gap_1() - .border_1() - .border_color(colors.border) - .bg(colors.background) - .text_color(colors.foreground) - .text_sm() - .font_weight(FontWeight::MEDIUM) - .child(div().truncate().child(column.clone())) + div() + .flex() + .items_center() + .justify_between() + .font_weight(gpui::FontWeight::BOLD) + .text_size(px(14.0 * zoom)) + .text_color(side_color) + .border_b(px(2.0 * zoom)) + .border_color(side_color) + .child(column.label.clone()) + .child( + div() + .flex() + .gap(px(4.0 * zoom)) .child( - Button::new(("flow-add-empty-column", ix)) - .icon(IconName::Plus) - .xsmall() + Button::new(("flow-send-to-document", column_index)) + .with_size(control_size) .ghost() - .tooltip("Add argument in column") - .on_click(cx.listener(move |editor, _, window, cx| { - editor.add_empty_at_column(ix, window, cx); - })), + .tooltip("Send to document") + .child(Icon::default().path("icons/file-input.svg").with_size(control_icon_size)) + .on_click(|_, _, _| {}), ) - .into_any_element() - })), + .child( + Button::new(("flow-add-column-orphan", column_index)) + .with_size(control_size) + .ghost() + .tooltip("Add orphan at top") + .icon(IconName::Plus) + .on_click(move |_, window, cx| { + add_editor.update(cx, |editor, cx| { + editor.set_annotation_tool(AnnotationTool::None, cx); + editor.add_orphan_at_column_top(column_index, cx); + editor.focus_active_cell(window, cx); + }); + }), + ), + ), ) - .child( - v_flex().gap_2().children( - children - .into_iter() - .map(|child| self.render_box_tree(child, &flow.columns, window, cx)), - ), - ), - ) - .into_any_element() - } - - #[hotpath::measure] - fn render_box_tree(&mut self, box_id: NodeId, columns: &[String], window: &mut Window, cx: &mut Context) -> gpui::AnyElement { - let Some(node) = self.document.node(&box_id).cloned() else { - return div().into_any_element(); - }; - if self - .document - .box_node(&box_id) - .is_some_and(|box_node| box_node.empty) - { - return h_flex() - .items_start() - .child(div().w(px(COLUMN_WIDTH)).h(px(10.0)).flex_none()) - .child( - v_flex().gap_2().children( - node - .children - .into_iter() - .map(|child| self.render_box_tree(child, columns, window, cx)), - ), - ) - .into_any_element(); - } - let children = node.children.clone(); - h_flex() - .items_start() - .child(self.render_box_cell(box_id.clone(), node, columns, window, cx)) - .when(!self.folded.contains(&box_id), |this| { - this.child( - v_flex().gap_2().children( - children - .into_iter() - .map(|child| self.render_box_tree(child, columns, window, cx)), - ), - ) - }) - .when(self.folded.contains(&box_id), |this| { - this.child( - Button::new(("flow-unfold", stable_element_id(&box_id))) - .icon(IconName::PanelRightOpen) - .xsmall() - .ghost() - .tooltip("Unfold") - .on_click(cx.listener(move |editor, _, _, cx| { - editor.folded.remove(&box_id); - cx.notify(); - })), - ) - }) - .into_any_element() - } - - #[hotpath::measure] - fn render_box_cell( - &mut self, - box_id: NodeId, - node: Node, - columns: &[String], - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let Some(box_node) = self.document.box_node(&box_id).cloned() else { - return div().into_any_element(); - }; - let input = self.ensure_box_input(&box_id, window, cx); - self.sync_input(&box_id, &input, &box_node.content, window, cx); - if self.focus_id.as_deref() == Some(box_id.as_str()) && !input.focus_handle(cx).is_focused(window) { - input.update(cx, |input, cx| input.focus(window, cx)); - } - - let selected = self.focus_id.as_deref() == Some(box_id.as_str()); - let colors = columns - .get(flow_box_column_index(node.level)) - .map(|column| flow_column_colors(column, cx)) - .unwrap_or_else(|| affirmative_flow_colors(cx)); - let weak = cx.theme().muted_foreground; - let id_for_click = box_id.clone(); - v_flex() - .id(("flow-box-cell", stable_element_id(&box_id))) - .w(px(COLUMN_WIDTH)) - .min_h(px(44.0)) - .p_1() - .border_1() - .border_color(if selected { colors.selected_border } else { colors.border }) - .bg(if selected { colors.selected_background } else { colors.background }) - .text_color(if box_node.crossed { weak } else { colors.foreground }) - .when(box_node.bold, |this| this.font_weight(FontWeight::BOLD)) - .when(box_node.crossed, |this| this.line_through()) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(cx.listener(move |editor, _, window, cx| { - editor.set_focus(Some(id_for_click.clone()), window, cx); - })) - .child( - h_flex() - .w_full() - .items_start() - .gap_1() - .when(box_node.is_extension, |this| { - this.child( - Icon::new(IconName::ArrowRight) - .xsmall() - .text_color(colors.foreground), + .child(div().h(px(12.0 * zoom)).flex_none()) + .children({ + // Render this column's cells in vertical (layout `top`) order, not sheet order. A cell's + // vertical band comes from the family tree, so after some moves sheet order diverges from + // top order; iterating in sheet order with push-down-only spacers would stack cells wrong + // (e.g. a first child's subtree rendering *below* a later sibling's). + let mut column_cells: Vec<_> = sheet.cells.iter().filter(|cell| cell.column_id == column.id).collect(); + column_cells.sort_by(|a, b| { + let top = |id| cell_layout.get(id).map(|layout| layout.top).unwrap_or(0.0); + top(&a.id).partial_cmp(&top(&b.id)).unwrap_or(std::cmp::Ordering::Equal) + }); + // Open the landing gap (if it belongs to this column) before the first cell at/after its + // top, shifting the rest of the column down by the gap's height. Everything else stays put. + let mut gap_pending = column_gap.filter(|(gap_column, _, _)| *gap_column == column_index).map(|(_, top, height)| (top, height)); + let mut previous_bottom = 0.0; + let mut shift = 0.0; + let mut elements: Vec = column_cells.into_iter().map(|cell| { + let id = cell.id; + let is_ghost = self.dragging_cell == Some(id); + let layout = cell_layout.get(&id).copied().unwrap_or_default(); + let gap_slot = if let Some((gap_top, gap_height)) = gap_pending + && layout.top >= gap_top - 0.5 + { + let gap_spacer = px((gap_top - previous_bottom).max(0.0)); + previous_bottom = gap_top + gap_height; + shift = gap_height; + gap_pending = None; + Some((gap_spacer, px(gap_height))) + } else { + None + }; + let cell_top = layout.top + shift; + let spacer_height = px((cell_top - previous_bottom).max(0.0)); + previous_bottom = cell_top + layout.height; + let label: SharedString = cell.summary.summary_text.to_string().into(); + let mut uses_summary_projection = cell.summary.uses_summary_projection; + let mut rendered_document = self.cell_documents.get(&id).cloned(); + if let Some(document) = rendered_document.as_mut() { + if !uses_summary_projection { + let restyled = { + let mut paragraphs = document.paragraphs.make_mut(); + if let Some(paragraph) = paragraphs.first_mut() { + paragraph.style = flowstate_document::PARAGRAPH_TAG; + true + } else { + false + } + }; + if restyled { + document.blocks = flowstate_document::BlockSeq::from_vec(flowstate_document::paragraph_blocks_from_paragraphs(&document.paragraphs.to_vec())); + uses_summary_projection = true; + } + } + apply_flow_cell_theme(document, &client_document_theme, side_color, cx.theme().background, self.board_zoom); + } + let cell_editor = self.cell_editors.get(&id).cloned(); + let reply_editor = cx.entity().clone(); + // Remote peer focus: peer-color outline + name chip on the card + // (spec Part C presence rendering). + let presence = self.presence_for_cell(id).cloned(); + let presence_color = presence + .as_ref() + .map(|presence| gpui::Hsla::from(gpui::rgb(presence.color_rgb))); + div() + .w_full() + .flex_col() + .when_some(gap_slot, |this, (gap_spacer, gap_height)| { + this.child(div().h(gap_spacer).flex_none()).child( + div() + .w_full() + .h(gap_height) + .rounded(px(2.0 * zoom)) + .bg(side_palette.active), + ) + }) + .when(spacer_height > px(0.0), |this| this.child(div().h(spacer_height).flex_none())) + .on_children_prepainted({ + let weak_editor = weak_editor.clone(); + move |bounds, _, cx| { + if let Some(card_bounds) = bounds.last().copied() { + let _ = weak_editor.update(cx, |editor, cx| editor.set_cell_bounds(id, card_bounds, cx)); + } + } + }) + .child( + div() + .id(("flow-cell", id.as_u128() as u64)) + .relative() + .w_full() + .min_h(px(54.0 * zoom)) + .p(px(10.0 * zoom)) + .rounded(px(6.0 * zoom)) + .border(px(if presence_color.is_some() { 2.0 * zoom } else { 1.0 * zoom })) + .border_color(if let Some(color) = presence_color { + color + } else if active == Some(id) { + side_palette.active + } else { + side_color.opacity(0.56) + }) + .bg(if active == Some(id) { + side_palette.active.opacity(0.14) + } else { + cx.theme().background + }) + .hover(|style| style.bg(side_palette.hover.opacity(0.12))) + .cursor_pointer() + .when(active != Some(id) && !is_ghost, |this| { + // The whole card (except the one being edited) is a drag handle; a plain click still + // selects because GPUI only starts a drag past a small movement threshold. + let weak = weak_editor.clone(); + let drag_label = label.clone(); + this.on_drag(FlowCellDrag { cell_id: id }, move |drag, _, _, cx| { + let _ = weak.update(cx, |editor, cx| editor.begin_cell_drag(drag.cell_id, cx)); + cx.new(|_| FlowCellDragPreview { label: drag_label.clone() }) + }) + }) + .on_mouse_down(MouseButton::Left, cx.listener(|editor, _, _, cx| { + if editor.annotation_tool != AnnotationTool::None { + editor.set_annotation_tool(AnnotationTool::None, cx); + } + cx.stop_propagation(); + })) + .on_click(cx.listener(move |editor, _, window, cx| { + editor.activate_cell(id, cx); + if let Some(cell_editor) = editor.cell_editors.get(&id) { + cell_editor.read(cx).focus_handle(cx).focus(window); + } + })) + // Ghost cells (the dragged subtree drawn in its previewed drop position) must not be drop + // targets. Otherwise the ghost sits under the cursor, captures the drag, resolves to a + // self-referential intent, and the placement oscillates and pins near the source/parent. + .when(!is_ghost, |this| { + this + .on_drag_move(cx.listener(move |editor, event: &DragMoveEvent, window, cx| { + // `on_drag_move` is dispatched in the CAPTURE phase to every registered element + // (not hit-tested), parent-before-child — so the column handler above has already + // run this frame. Each handler hit-tests itself against its own bounds; the actual + // column-vs-cell arbitration is the `cursor_over_live_cell` guard in the column + // handler, NOT the `stop_propagation` below (which fires too late to gate the + // already-executed column handler and only suppresses later-painted siblings). + if !event.bounds.contains(&event.event.position) { + return; + } + // Left 60% reorders siblings (before/after by vertical half); right 40% nests as a + // child (first/last by vertical half). The wider child zone is far easier to hit + // than the old 28% strip, and top/bottom finally makes "first child" reachable. + let in_child_zone = event.event.position.x >= event.bounds.left() + event.bounds.size.width * 0.6; + let upper_half = event.event.position.y < event.bounds.top() + event.bounds.size.height / 2.0; + let destination = if in_child_zone && can_receive_child { + if upper_half { + FlowDropIntent::FirstChildOf(id) + } else { + FlowDropIntent::LastChildOf(id) + } + } else if upper_half { + FlowDropIntent::BeforeSibling(id) + } else { + FlowDropIntent::AfterSibling(id) + }; + editor.update_cell_drop(destination, cx); + editor.update_drag_autoscroll(event.event.position, window, cx); + editor.log_drag_over_cell(id, event.event.position, event.bounds, destination); + cx.stop_propagation(); + })) + .on_drop(cx.listener(|editor, drag: &FlowCellDrag, _, cx| { + editor.finish_cell_drop(drag.cell_id, cx); + cx.stop_propagation(); + })) + }) + .when(is_ghost, |this| this.opacity(0.5).border_dashed().border_color(side_palette.active)) + .when_some(presence, |this, presence| { + let color = gpui::Hsla::from(gpui::rgb(presence.color_rgb)); + let chip: SharedString = if presence.editing { + format!("{} ✎", presence.name).into() + } else { + presence.name.clone().into() + }; + this.child( + div() + .absolute() + .top(px(-10.0 * zoom)) + .left(px(6.0 * zoom)) + .px(px(6.0 * zoom)) + .rounded(px(4.0 * zoom)) + .text_size(px(10.0 * zoom)) + .bg(color) + .text_color(gpui::white()) + .child(chip), + ) + }) + .child( + div() + .id(("flow-cell-drag-handle", id.as_u128() as u64)) + .absolute() + .top(px(2.0 * zoom)) + .right(px(4.0 * zoom)) + .text_size(px(14.0 * zoom)) + .cursor_move() + .text_color(cx.theme().muted_foreground) + .child("⠿") + .on_mouse_down(MouseButton::Left, cx.listener(move |editor, _, _, cx| editor.activate_cell(id, cx))) + .on_drag(FlowCellDrag { cell_id: id }, { + let weak = weak_editor.clone(); + let drag_label = label.clone(); + move |drag, _, _, cx| { + let _ = weak.update(cx, |editor, cx| editor.begin_cell_drag(drag.cell_id, cx)); + cx.new(|_| FlowCellDragPreview { label: drag_label.clone() }) + } + }), + ) + .when(can_receive_child, |this| { + this.child( + Button::new(("flow-cell-reply", id.as_u128() as u64)) + .absolute() + .top(px(24.0 * zoom)) + .right(px(2.0 * zoom)) + .with_size(control_size) + .ghost() + .tooltip("Add first reply") + .child(Icon::default().path("icons/message-square-reply.svg").with_size(control_icon_size)) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + reply_editor.update(cx, |editor, cx| { + editor.set_annotation_tool(AnnotationTool::None, cx); + editor.add_first_response_to(id, cx); + editor.focus_active_cell(window, cx); + }); + }), + ) + }) + .child(if active == Some(id) { + cell_editor.map_or_else(|| div().child(label).into_any_element(), |editor| editor.into_any_element()) + } else { + rendered_document.map_or_else( + || div().child(label).into_any_element(), + |document| RichTextDocumentElement::new(document).with_invisibility_mode(uses_summary_projection).into_any_element(), + ) + }), ) + .into_any_element() + }).collect(); + if let Some((gap_top, gap_height)) = gap_pending { + let gap_spacer = px((gap_top - previous_bottom).max(0.0)); + elements.push( + div() + .w_full() + .flex_col() + .child(div().h(gap_spacer).flex_none()) + .child( + div() + .w_full() + .h(px(gap_height)) + .rounded(px(2.0 * zoom)) + .bg(side_palette.active), + ) + .into_any_element(), + ); + } + elements }) + })) + .child( + canvas( + |_, _, _| {}, + move |_canvas_bounds, _, window, cx| { + let Some(editor) = weak_connector_editor.upgrade() else { + return; + }; + let connector_bounds = &editor.read(cx).cell_bounds; + for (parent_id, child_ids) in &connector_families { + let Some(parent) = connector_bounds.get(parent_id) else { + continue; + }; + let children = child_ids + .iter() + .filter_map(|id| connector_bounds.get(id).copied()) + .collect::>(); + paint_connector_family(*parent, &children, cx.theme().foreground, window); + } + }, + ) + .absolute() + .inset_0(), + ) + .child( + div() + .id("flow-annotation-layer") + .absolute() + .inset_0() .child( - div().flex_1().child( - Input::new(&input) - .appearance(false) - .bordered(false) - .focus_bordered(false) - .text_color(if box_node.crossed { weak } else { colors.foreground }) - .placeholder_color(colors.foreground.opacity(0.62)) - .w_full(), - ), + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + for stroke in &strokes { + let color = gpui::Hsla::from(rgba(stroke.style.color_rgba)); + paint_stroke( + bounds.origin, + &stroke.points, + px(stroke.style.width * zoom), + color.opacity(color.a * stroke.style.opacity), + zoom, + window, + ); + } + if !draft.is_empty() { + paint_stroke(bounds.origin, &draft, px(4.0 * zoom), gpui::Hsla::from(rgba(0xf59e_0bff)).opacity(0.55), zoom, window); + } + }, + ) + .absolute() + .size_full(), ), ) .into_any_element() } } -impl EventEmitter<()> for FlowEditor {} +impl EventEmitter for FlowEditor {} -#[hotpath::measure_all] impl Focusable for FlowEditor { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } -#[hotpath::measure_all] impl Render for FlowEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.apply_pending_camera_center(); + self.refresh_active_cell_theme(cx); + if !cx.has_active_drag() && (self.pending_cell_drop.is_some() || self.dragging_cell.is_some()) { + self.pending_cell_drop = None; + self.dragging_cell = None; + self.drag_autoscroll = None; + // A drag that ended without an accepted drop (released over empty space) still flushes, tagged + // as an uncommitted attempt. + self.finish_drag_log(None, false); + } + if self.pan_drag.is_some() { + let editor = cx.entity(); + window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| { + if phase.bubble() && event.button == MouseButton::Left { + editor.update(cx, |editor, cx| editor.finish_space_pan(cx)); + } + }); + } + let grid_scroll = self.board_scroll.clone(); + let board_zoom = self.board_zoom; div() + .id("flow-editor") + .relative() .size_full() - .overflow_hidden() .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::on_key_down)) - .drag_over::(|style, _, _, cx| style.border_1().border_color(cx.theme().drag_border)) - .on_drop(cx.listener(|editor, drag: &ToolkitTextDrag, window, cx| { - editor.insert_toolkit_text(&drag.title, &drag.text, window, cx); + .on_key_down(cx.listener(|editor, event: &KeyDownEvent, window, cx| { + // Only arm panning when the board itself holds focus. Otherwise a space typed inside a focused + // cell editor would silently arm a pan (and a later click would pan instead of act). + if event.keystroke.key == "space" && editor.focus_handle.is_focused(window) { + editor.space_pan_armed = true; + cx.stop_propagation(); + cx.notify(); + } })) - .child(self.render_main(window, cx)) - } -} - -#[hotpath::measure] -fn sanitize_input_value(value: String) -> String { - value.replace(['\r', '\n'], " ") -} - -#[derive(Clone, Copy, Debug)] -pub struct FlowSideColors { - pub background: Hsla, - pub border: Hsla, - pub foreground: Hsla, - pub selected_background: Hsla, - pub selected_border: Hsla, -} - -#[hotpath::measure] -pub fn affirmative_flow_colors(cx: &mut App) -> FlowSideColors { - FlowSideColors { - background: cx.theme().primary_hover, - border: cx.theme().primary, - foreground: cx.theme().primary_foreground, - selected_background: cx.theme().primary_active, - selected_border: cx.theme().primary_active, - } -} - -#[hotpath::measure] -pub fn flow_column_colors(column: &str, cx: &mut App) -> FlowSideColors { - let affirmative = affirmative_flow_colors(cx); - match debate_column_side(column) { - DebateColumnSide::Negative => affirmative.inverse(), - DebateColumnSide::Affirmative | DebateColumnSide::Unknown => affirmative, - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DebateColumnSide { - Affirmative, - Negative, - Unknown, -} - -#[hotpath::measure] -fn debate_column_side(column: &str) -> DebateColumnSide { - column - .split(|ch: char| !ch.is_ascii_alphanumeric()) - .find_map(debate_column_token_side) - .unwrap_or(DebateColumnSide::Unknown) -} - -#[hotpath::measure] -fn debate_column_token_side(token: &str) -> Option { - let token = token.trim().to_ascii_uppercase(); - if token.is_empty() { - return None; - } - - const AFFIRMATIVE_TOKENS: &[&str] = &["A1", "A2", "A3", "P1", "P2", "PC", "PR", "PM", "DPM", "MG", "GW", "PW"]; - const NEGATIVE_TOKENS: &[&str] = &["N1", "N2", "N3", "O1", "O2", "CC", "CR", "LO", "DLO", "MO", "CW", "OW", "OR"]; - - if token.contains("NEG") - || token.contains("CON") - || token.contains("OPP") - || token.contains("NC") - || token.contains("NR") - || token.contains("NFF") - || NEGATIVE_TOKENS.contains(&token.as_str()) - || token == "NS" - || token.ends_with('N') - { - return Some(DebateColumnSide::Negative); - } - - if token.contains("AFF") - || token.contains("PRO") - || token.contains("PROP") - || token.contains("AC") - || token.contains("AR") - || AFFIRMATIVE_TOKENS.contains(&token.as_str()) - || token == "AS" - || token.ends_with('A') - { - return Some(DebateColumnSide::Affirmative); - } - - None -} - -#[hotpath::measure_all] -impl FlowSideColors { - fn inverse(self) -> Self { - Self { - background: inverse_color(self.background), - border: inverse_color(self.border), - foreground: inverse_color(self.foreground), - selected_background: inverse_color(self.selected_background), - selected_border: inverse_color(self.selected_border), - } - } -} - -#[hotpath::measure] -fn inverse_color(color: Hsla) -> Hsla { - let rgb = color.to_rgb(); - Hsla::from(gpui::Rgba { - r: 1.0 - rgb.r, - g: 1.0 - rgb.g, - b: 1.0 - rgb.b, - a: rgb.a, - }) -} - -#[hotpath::measure] -fn flow_box_column_index(level: i32) -> usize { - level.saturating_sub(1).max(0) as usize -} - -#[hotpath::measure] -fn stable_element_id(value: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - value.hash(&mut hasher); - hasher.finish() -} - -#[cfg(test)] -mod tests { - use super::{DebateColumnSide, debate_column_side, flow_box_column_index}; - - #[test] - #[hotpath::measure] - fn classifies_affirmative_speech_columns() { - for column in ["1AC", "2AC", "1AR", "2AR", "AC", "AFF", "Q/2A", "A3", "P1", "PC", "PM", "MG"] { - assert_eq!(debate_column_side(column), DebateColumnSide::Affirmative, "{column}"); - } - } - - #[test] - #[hotpath::measure] - fn classifies_negative_speech_columns() { - for column in ["1NC", "2NC/1NR", "1NR", "2NR", "NC", "NFF", "Q/1N", "N3", "O1", "CC", "LO", "MO"] { - assert_eq!(debate_column_side(column), DebateColumnSide::Negative, "{column}"); - } - } - - #[test] - #[hotpath::measure] - fn maps_flow_box_levels_to_column_indices() { - assert_eq!(flow_box_column_index(1), 0); - assert_eq!(flow_box_column_index(2), 1); - assert_eq!(flow_box_column_index(3), 2); + .on_key_up(cx.listener(|editor, event: &KeyUpEvent, _, cx| { + if event.keystroke.key == "space" && editor.space_pan_armed { + editor.space_pan_armed = false; + editor.finish_space_pan(cx); + cx.stop_propagation(); + } + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|editor, event: &MouseDownEvent, _, cx| { + if editor.space_pan_armed { + editor.begin_pan(event.position, cx); + cx.stop_propagation(); + } + }), + ) + .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, window, cx| editor.queue_pan(event.position, window, cx))) + .on_mouse_up( + MouseButton::Left, + cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_space_pan(cx)), + ) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let spacing = px(24.0 * board_zoom); + let scale = window.scale_factor(); + let (dot_size, dot_opacity) = grid_dot_metrics(board_zoom, scale); + let offset = grid_scroll.offset(); + let mut x = offset.x % spacing; + let color = cx.theme().border.opacity(0.56 * dot_opacity); + let snap = |value: gpui::Pixels| px((value.as_f32() * scale).round() / scale); + while x < bounds.size.width { + let mut y = offset.y % spacing; + while y < bounds.size.height { + window.paint_quad(gpui::fill( + gpui::Bounds::new(bounds.origin + point(snap(x), snap(y)), gpui::size(dot_size, dot_size)), + color, + )); + y += spacing; + } + x += spacing; + } + }, + ) + .absolute() + .inset_0(), + ) + .child( + div() + .id("flow-board-scroll") + .relative() + .size_full() + .overflow_scroll() + .track_scroll(&self.board_scroll) + .cursor(if self.pan_drag.is_some() { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .when(self.annotation_tool == AnnotationTool::None && !self.space_pan_armed, |this| { + this.on_mouse_down( + MouseButton::Left, + cx.listener(|editor, event: &MouseDownEvent, _, cx| { + editor.begin_pan(event.position, cx); + cx.stop_propagation(); + }), + ) + }) + .on_scroll_wheel(cx.listener(|editor, event: &ScrollWheelEvent, window, cx| { + if event.modifiers.shift { + let delta = event.delta.pixel_delta(window.line_height()); + let mut offset = editor.board_scroll.offset(); + offset.x += delta.y + delta.x; + editor.set_user_scroll_offset(offset); + cx.stop_propagation(); + cx.notify(); + } else if !event.modifiers.control { + editor.camera_center = None; + cx.on_next_frame(window, |editor, _, _| editor.sync_camera_center_from_scroll()); + } + })) + .when(self.annotation_tool != AnnotationTool::None && !self.space_pan_armed, |this| { + this + .on_mouse_down( + MouseButton::Left, + cx.listener(|editor, event: &MouseDownEvent, _, cx| editor.begin_annotation(event.position, cx)), + ) + .on_mouse_move(cx.listener(|editor, event: &MouseMoveEvent, _, cx| editor.continue_annotation(event.position, cx))) + .on_mouse_up( + MouseButton::Left, + cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_annotation(cx)), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|editor, _: &MouseUpEvent, _, cx| editor.finish_annotation(cx)), + ) + }) + .child( + canvas( + { + let weak = cx.entity().downgrade(); + move |bounds, _, cx| { + let _ = weak.update(cx, |editor, _| editor.set_viewport_origin(bounds.origin)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(match self.active_sheet { + Some(sheet) => self.render_sheet(sheet, cx).into_any_element(), + None => div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child("Create a sheet to begin flowing") + .into_any_element(), + }), + ) + .child( + div() + .absolute() + .inset_0() + .child(Scrollbar::vertical(&self.board_scroll).scrollbar_show(ScrollbarShow::Always)) + .child(Scrollbar::horizontal(&self.board_scroll).scrollbar_show(ScrollbarShow::Always)), + ) } } diff --git a/crates/flowstate/src/flow/editor/annotation.rs b/crates/flowstate/src/flow/editor/annotation.rs new file mode 100644 index 00000000..e5ddceb3 --- /dev/null +++ b/crates/flowstate/src/flow/editor/annotation.rs @@ -0,0 +1,226 @@ +use flowstate_flow::{AnnotationOriginator, AnnotationStroke, BoardPoint, BoardRect, StrokeStyle}; +use gpui::{Context, Hsla, PathBuilder, Pixels, Point, Window, point, px}; +use gpui_component::PixelsExt as _; + +use super::{AnnotationTool, FlowEditor}; + +impl FlowEditor { + pub fn set_annotation_tool(&mut self, tool: AnnotationTool, cx: &mut Context) { + self.annotation_tool = tool; + cx.notify(); + } + + pub fn toggle_annotation_tool(&mut self, tool: AnnotationTool, cx: &mut Context) { + self.annotation_tool = if self.annotation_tool == tool { AnnotationTool::None } else { tool }; + cx.notify(); + } + + pub fn toggle_annotations_visible(&mut self, cx: &mut Context) { + let Some(sheet) = self.active_sheet else { + return; + }; + if !self.hidden_annotation_sheets.remove(&sheet) { + self.hidden_annotation_sheets.insert(sheet); + } + cx.notify(); + } + + pub fn set_originator_annotations_hidden(&mut self, originator: AnnotationOriginator, hidden: bool, cx: &mut Context) { + if hidden { + self.hidden_annotation_originators.insert(originator); + } else { + self.hidden_annotation_originators.remove(&originator); + } + cx.notify(); + } + + pub fn set_local_annotation_originator(&mut self, originator: AnnotationOriginator) { + self.local_annotation_originator = originator; + } + + pub fn clear_annotations(&mut self, cx: &mut Context) { + let originator = self.local_annotation_originator.clone(); + if self.apply_intent(flowstate_flow::FlowIntent::ClearAnnotations { sheet_id: None, originator }, cx) { + self.changed(self.active_cell, cx); + } + } + + fn annotation_point(&self, position: Point) -> BoardPoint { + BoardPoint { + x: (position.x.as_f32() - self.viewport_origin.x) / self.board_zoom, + y: (position.y.as_f32() - self.viewport_origin.y) / self.board_zoom, + } + } + + pub(super) fn set_viewport_origin(&mut self, origin: Point) { + self.viewport_origin = BoardPoint { + x: origin.x.as_f32(), + y: origin.y.as_f32(), + }; + } + + pub(super) fn begin_annotation(&mut self, position: Point, cx: &mut Context) { + match self.annotation_tool { + AnnotationTool::Marker => { + self.drawing_points.clear(); + self.drawing_points.push(self.annotation_point(position)); + }, + AnnotationTool::Eraser => self.erase_at(self.annotation_point(position), cx), + AnnotationTool::None => {}, + } + cx.notify(); + } + + pub(super) fn continue_annotation(&mut self, position: Point, cx: &mut Context) { + if self.annotation_tool == AnnotationTool::Eraser { + self.erase_at(self.annotation_point(position), cx); + return; + } + if self.annotation_tool != AnnotationTool::Marker || self.drawing_points.is_empty() { + return; + } + let point = self.annotation_point(position); + let should_append = self.drawing_points.last().is_none_or(|last| { + let dx = point.x - last.x; + let dy = point.y - last.y; + dx * dx + dy * dy >= 4.0 + }); + if should_append { + self.drawing_points.push(point); + cx.notify(); + } + } + + pub(super) fn finish_annotation(&mut self, cx: &mut Context) { + let Some(sheet_id) = self.active_sheet else { + self.drawing_points.clear(); + return; + }; + if self.drawing_points.len() < 2 { + self.drawing_points.clear(); + return; + } + let points = simplify_stroke(&std::mem::take(&mut self.drawing_points), 1.5); + let bbox = stroke_bbox(&points); + let stroke = AnnotationStroke { + id: uuid::Uuid::new_v4(), + sheet_id, + originator: self.local_annotation_originator.clone(), + points, + style: StrokeStyle { + color_rgba: 0xf59e_0bff, + width: 4.0, + opacity: 0.55, + }, + bbox, + }; + if self.apply_intent(flowstate_flow::FlowIntent::AddAnnotation { sheet_id, stroke }, cx) { + self.changed(self.active_cell, cx); + } + } + + fn erase_at(&mut self, point: BoardPoint, cx: &mut Context) { + let Some(sheet) = self + .active_sheet + .and_then(|sheet_id| self.board.sheet(sheet_id)) + else { + return; + }; + let radius = 10.0; + let touched = sheet + .annotations + .iter() + .find(|stroke| { + stroke.originator == self.local_annotation_originator + && point.x >= stroke.bbox.min.x - radius + && point.x <= stroke.bbox.max.x + radius + && point.y >= stroke.bbox.min.y - radius + && point.y <= stroke.bbox.max.y + radius + && stroke + .points + .windows(2) + .any(|segment| segment_distance(point, segment[0], segment[1]) <= radius) + }) + .map(|stroke| stroke.id); + let sheet_id = sheet.id; + if let Some(stroke_id) = touched { + let originator = self.local_annotation_originator.clone(); + if self.apply_intent( + flowstate_flow::FlowIntent::DeleteAnnotation { + sheet_id, + stroke_id, + originator, + }, + cx, + ) { + self.changed(self.active_cell, cx); + } + } + } +} + +fn stroke_bbox(points: &[BoardPoint]) -> BoardRect { + let mut min = points[0]; + let mut max = points[0]; + for point in &points[1..] { + min.x = min.x.min(point.x); + min.y = min.y.min(point.y); + max.x = max.x.max(point.x); + max.y = max.y.max(point.y); + } + BoardRect { min, max } +} + +fn simplify_stroke(points: &[BoardPoint], minimum_distance: f32) -> Vec { + let Some(first) = points.first().copied() else { + return Vec::new(); + }; + let mut simplified = Vec::with_capacity(points.len()); + simplified.push(first); + for window in points.windows(3) { + let smoothed = BoardPoint { + x: (window[0].x + window[1].x + window[2].x) / 3.0, + y: (window[0].y + window[1].y + window[2].y) / 3.0, + }; + if simplified + .last() + .is_none_or(|previous| (smoothed.x - previous.x).hypot(smoothed.y - previous.y) >= minimum_distance) + { + simplified.push(smoothed); + } + } + if let Some(last) = points.last().copied() + && simplified.last() != Some(&last) + { + simplified.push(last); + } + simplified +} + +fn segment_distance(point: BoardPoint, start: BoardPoint, end: BoardPoint) -> f32 { + let dx = end.x - start.x; + let dy = end.y - start.y; + let length_squared = dx * dx + dy * dy; + let t = if length_squared == 0.0 { + 0.0 + } else { + (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0) + }; + let nearest_x = start.x + t * dx; + let nearest_y = start.y + t * dy; + (point.x - nearest_x).hypot(point.y - nearest_y) +} + +pub(super) fn paint_stroke(origin: Point, points: &[BoardPoint], width: Pixels, color: Hsla, zoom: f32, window: &mut Window) { + let Some(first) = points.first() else { + return; + }; + let mut path = PathBuilder::stroke(width); + path.move_to(point(origin.x + px(first.x * zoom), origin.y + px(first.y * zoom))); + for point_value in &points[1..] { + path.line_to(point(origin.x + px(point_value.x * zoom), origin.y + px(point_value.y * zoom))); + } + if let Ok(path) = path.build() { + window.paint_path(path, color); + } +} diff --git a/crates/flowstate/src/flow/editor/cell_editing.rs b/crates/flowstate/src/flow/editor/cell_editing.rs new file mode 100644 index 00000000..1dc14454 --- /dev/null +++ b/crates/flowstate/src/flow/editor/cell_editing.rs @@ -0,0 +1,119 @@ +//! Per-cell editor wiring (flow spec Part C, editor rewiring). +//! +//! Each open cell's `RichTextEditor` is driven by a real +//! [`flowstate_collab::flow::FlowCellAuthority`] through +//! `RichTextEditor::set_write_authority` — the identical injection the .db8 +//! body editor uses (invariant 5). The record-era observer loop (re-encode +//! whole cell → `replace_cell_document` on every editor change) is GONE: +//! keystrokes commit through the gated intent path, and this flow editor +//! only reacts to the editor's notifications to refresh its board copy. + +use flowstate_flow::CellId; +use gpui::{AppContext as _, Context}; +use gpui_component::ActiveTheme as _; + +use crate::{app_settings::load_document_theme, flow::cell_theme::apply_flow_cell_theme, rich_text_element::RichTextEditor}; + +use super::FlowEditor; + +impl FlowEditor { + pub(super) fn ensure_cell_editor(&mut self, cell_id: CellId, cx: &mut Context) { + if self.cell_editors.contains_key(&cell_id) { + return; + } + let Ok(mut document) = self.handle.open_cell(cell_id) else { + return; + }; + let uses_summary_projection = self + .board + .cell(cell_id) + .is_some_and(|(_, cell)| cell.summary.uses_summary_projection); + let text_color = self.cell_text_color(cell_id, cx); + apply_flow_cell_theme( + &mut document, + &load_document_theme(), + text_color, + cx.theme().background, + self.board_zoom(), + ); + let authority = self.handle.cell_authority(cell_id); + let editor = cx.new(|cx| { + let mut editor = RichTextEditor::new_with_path(document.clone(), None, cx); + editor.set_invisibility_mode(uses_summary_projection, cx); + editor.update_config( + |config| { + config.allow_paragraph_breaks = false; + config.flow_cell_surface = true; + config.show_section_collapse_controls = false; + config.caret_color = Some(text_color); + }, + cx, + ); + editor.set_write_authority(authority, document, cx); + editor + }); + // The editor commits through its authority; this subscription only keeps + // the BOARD side current (summary refreshes ride the board stream) and + // the dirty flag honest — caret-only notifies are ignored. + let subscription = cx.subscribe(&editor, move |flow, _editor, event: &crate::rich_text_element::EditorEvent, cx| { + match event { + crate::rich_text_element::EditorEvent::Changed { .. } => { + flow.sync_board_from_handle(cx); + if !flow.dirty { + flow.dirty = true; + cx.emit(super::FlowEditorEvent::Changed); + } + cx.notify(); + }, + // Caret motion inside the cell rides the presence channel (step 11) — + // the session subscription debounces + republishes own presence. + crate::rich_text_element::EditorEvent::SelectionChanged { .. } => { + cx.emit(super::FlowEditorEvent::CellSelectionChanged); + }, + _ => {}, + } + }); + self.cell_editors.insert(cell_id, editor); + self + .cell_editor_themes + .insert(cell_id, (text_color, cx.theme().background, self.board_zoom().to_bits())); + self.cell_editor_subscriptions.insert(cell_id, subscription); + } + + /// Pump remote projection changes into every open cell editor (the + /// collaboration session calls this after imports; solo tabs never need it). + pub fn sync_cell_editors_from_authority(&mut self, cx: &mut Context) { + for editor in self.cell_editors.values() { + editor.update(cx, |editor, cx| editor.sync_projection_from_authority(cx)); + } + // Remote edits move text under peers' carets; re-resolve them against the + // refreshed cell state. + self.sync_external_cell_carets(cx); + } + + pub(super) fn refresh_active_cell_theme(&mut self, cx: &mut Context) { + let Some(cell_id) = self.active_cell else { + return; + }; + let Some(editor) = self.cell_editors.get(&cell_id).cloned() else { + return; + }; + let text_color = self.cell_text_color(cell_id, cx); + let signature = (text_color, cx.theme().background, self.board_zoom().to_bits()); + if self.cell_editor_themes.get(&cell_id) == Some(&signature) { + return; + } + let Ok(mut document) = self.handle.cell_preview(cell_id) else { + return; + }; + apply_flow_cell_theme( + &mut document, + &load_document_theme(), + text_color, + cx.theme().background, + self.board_zoom(), + ); + editor.update(cx, |editor, cx| editor.install_canonical_projection(document, cx)); + self.cell_editor_themes.insert(cell_id, signature); + } +} diff --git a/crates/flowstate/src/flow/editor/connector.rs b/crates/flowstate/src/flow/editor/connector.rs new file mode 100644 index 00000000..54584800 --- /dev/null +++ b/crates/flowstate/src/flow/editor/connector.rs @@ -0,0 +1,73 @@ +use gpui::{Bounds, Hsla, PathBuilder, Pixels, Point, Window, point, px}; +use gpui_component::PixelsExt as _; + +#[derive(Debug, PartialEq)] +struct ConnectorGeometry { + start: Point, + midpoint_x: Pixels, + children: Vec>, +} + +pub(super) fn paint_connector_family(parent: Bounds, children: &[Bounds], color: Hsla, window: &mut Window) { + let Some(geometry) = connector_geometry(parent, children, window.scale_factor()) else { + return; + }; + let mut path = PathBuilder::stroke(px(1.0)); + path.move_to(geometry.start); + path.line_to(point(geometry.midpoint_x, geometry.start.y)); + + let min_y = geometry + .children + .iter() + .map(|child| child.y) + .fold(geometry.start.y, Pixels::min); + let max_y = geometry + .children + .iter() + .map(|child| child.y) + .fold(geometry.start.y, Pixels::max); + if min_y != max_y { + path.move_to(point(geometry.midpoint_x, min_y)); + path.line_to(point(geometry.midpoint_x, max_y)); + } + for child in geometry.children { + path.move_to(point(geometry.midpoint_x, child.y)); + path.line_to(child); + } + if let Ok(path) = path.build() { + window.paint_path(path, color); + } +} + +fn connector_geometry(parent: Bounds, children: &[Bounds], device_scale: f32) -> Option { + let first_child = children.first()?; + let snap = |value: Pixels| px(((value.as_f32() * device_scale).floor() + 0.5) / device_scale); + let start = point(snap(parent.right()), snap(parent.center().y)); + let midpoint_x = snap(start.x + (first_child.left() - start.x) / 2.0); + let children = children + .iter() + .map(|child| point(snap(child.left()), snap(child.center().y))) + .collect(); + Some(ConnectorGeometry { start, midpoint_x, children }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Bounds, size}; + + #[test] + fn connector_targets_each_child_midpoint() { + let parent = Bounds::new(point(px(0.0), px(10.0)), size(px(100.0), px(40.0))); + let children = [ + Bounds::new(point(px(200.0), px(10.0)), size(px(100.0), px(80.0))), + Bounds::new(point(px(200.0), px(110.0)), size(px(100.0), px(40.0))), + ]; + + let geometry = connector_geometry(parent, &children, 1.0).unwrap(); + + assert_eq!(geometry.start.y, px(30.5)); + assert_eq!(geometry.children[0].y, px(50.5)); + assert_eq!(geometry.children[1].y, px(130.5)); + } +} diff --git a/crates/flowstate/src/flow/editor/drag_drop.rs b/crates/flowstate/src/flow/editor/drag_drop.rs new file mode 100644 index 00000000..77e6b372 --- /dev/null +++ b/crates/flowstate/src/flow/editor/drag_drop.rs @@ -0,0 +1,279 @@ +use flowstate_flow::{CellId, FlowDropIntent, Sheet, board_ops}; +use gpui::{Context, IntoElement, Pixels, Point, Render, SharedString, Window, div, point, prelude::*, px}; +use gpui_component::ActiveTheme as _; +use gpui_component::PixelsExt as _; + +use super::FlowEditor; + +#[derive(Clone)] +pub(super) struct FlowCellDrag { + pub(super) cell_id: CellId, +} + +/// Which edge of the target cell to accent as the drop indicator. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum DropEdge { + Before, + After, + Child, +} + +pub(super) struct FlowCellDragPreview { + pub(super) label: SharedString, +} + +impl Render for FlowCellDragPreview { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .max_w(px(280.0)) + .px_2() + .py_1() + .rounded(cx.theme().radius) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .text_color(cx.theme().popover_foreground) + .overflow_hidden() + .child(if self.label.is_empty() { + SharedString::from("Argument") + } else { + self.label.clone() + }) + } +} + +impl FlowEditor { + /// Called when a card drag actually starts (past the drag threshold). Recording the source cell + /// lets [`Self::drag_preview_sheet`] reflow the board as if the cell had already been picked up. + pub(super) fn begin_cell_drag(&mut self, cell_id: CellId, cx: &mut Context) { + if self.dragging_cell != Some(cell_id) { + self.dragging_cell = Some(cell_id); + self.pending_cell_drop = None; + self.start_drag_log(cell_id); + cx.notify(); + } + } + + /// The cell (and which edge of it) to accent as the current drop target, or `None` when there's no + /// valid landing. Only landings the committed move would accept are shown, so hovering an illegal + /// target (e.g. the cell's own descendant) shows nothing. + pub(super) fn drag_drop_target(&self, sheet: &Sheet) -> Option<(CellId, DropEdge)> { + let dragging = self.dragging_cell?; + let intent = self.pending_cell_drop?; + let sheet_id = self.active_sheet?; + board_ops::preview_move_cell_subtree(&self.board, sheet_id, dragging, intent)?; + match intent { + FlowDropIntent::BeforeSibling(target) => Some((target, DropEdge::Before)), + FlowDropIntent::AfterSibling(target) => Some((target, DropEdge::After)), + // Nesting maps to the boundary of the parent's existing children so the exact insertion point is + // visible; with no children yet, accent the parent's child-side edge. + FlowDropIntent::FirstChildOf(target) => Some( + sheet + .cells + .iter() + .find(|cell| cell.parent_id == Some(target)) + .map_or((target, DropEdge::Child), |child| (child.id, DropEdge::Before)), + ), + FlowDropIntent::LastChildOf(target) => Some( + sheet + .cells + .iter() + .rev() + .find(|cell| cell.parent_id == Some(target)) + .map_or((target, DropEdge::Child), |child| (child.id, DropEdge::After)), + ), + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + } => { + if let Some(cell) = sheet.cells.get(insertion_index) { + Some((cell.id, DropEdge::Before)) + } else { + let column = self + .board + .format + .sheet_type(sheet.sheet_type_id)? + .columns + .get(column_index)? + .id; + sheet + .cells + .iter() + .rev() + .find(|cell| cell.column_id == column) + .map(|cell| (cell.id, DropEdge::After)) + } + }, + } + } + + /// Whether the pointer is over a cell other than the one being dragged, so the column handler can + /// defer to that cell's own drop zones. The dragged cell holds its slot as a faded placeholder, so + /// the pointer passes through it to the column beneath. + pub(super) fn cursor_over_live_cell(&self, position: Point) -> bool { + self + .cell_bounds + .iter() + .any(|(id, bounds)| Some(*id) != self.dragging_cell && bounds.contains(&position)) + } + + /// A parent in `parent_column` with no children yet whose card vertically contains `y`, so a drop in + /// the (empty) `child_column` beside it can adopt it. The dragged cell is skipped so it can't parent + /// to itself. + fn childless_parent_at_row(&self, parent_column: usize, child_column: usize, y: gpui::Pixels) -> Option { + let sheet = self.active_sheet.and_then(|id| self.board.sheet(id))?; + let definition = self.board.format.sheet_type(sheet.sheet_type_id)?; + let parent_column = definition.columns.get(parent_column)?.id; + let _ = definition.columns.get(child_column)?; + sheet + .cells + .iter() + .filter(|cell| cell.column_id == parent_column && Some(cell.id) != self.dragging_cell) + .filter(|cell| { + !sheet + .cells + .iter() + .any(|other| other.parent_id == Some(cell.id)) + }) + .find(|cell| { + self + .cell_bounds + .get(&cell.id) + .is_some_and(|bounds| y >= bounds.top() && y <= bounds.bottom()) + }) + .map(|cell| cell.id) + } + + pub(super) fn update_cell_drop(&mut self, destination: FlowDropIntent, cx: &mut Context) { + if self.pending_cell_drop != Some(destination) { + self.pending_cell_drop = Some(destination); + cx.notify(); + } + } + + pub(super) fn update_column_drop(&mut self, column_index: usize, y: gpui::Pixels, cx: &mut Context) { + let Some(sheet_id) = self.active_sheet else { + return; + }; + let Some(sheet) = self.board.sheet(sheet_id) else { + return; + }; + let Some(definition) = self.board.format.sheet_type(sheet.sheet_type_id) else { + return; + }; + let Some(column) = definition.columns.get(column_index) else { + return; + }; + let column_cells: Vec<_> = sheet + .cells + .iter() + .enumerate() + .filter(|(_, cell)| cell.column_id == column.id) + .collect(); + let below_position = column_cells.iter().position(|(_, cell)| { + self + .cell_bounds + .get(&cell.id) + .is_some_and(|bounds| y < bounds.center().y) + }); + if let Some(below_position) = below_position + && let Some((_, above)) = below_position + .checked_sub(1) + .and_then(|position| column_cells.get(position)) + && let Some((_, below)) = column_cells.get(below_position) + && above.parent_id == below.parent_id + { + self.update_cell_drop(FlowDropIntent::BeforeSibling(below.id), cx); + return; + } + // The natural "make a child" gesture is dropping in the child column beside the parent — but a + // childless parent has nothing there, so it would otherwise fall through to an orphan drop. Adopt + // it as the parent's child when the pointer sits at that parent's row. + if let Some(parent) = column_index + .checked_sub(1) + .and_then(|parent_column| self.childless_parent_at_row(parent_column, column_index, y)) + { + self.update_cell_drop(FlowDropIntent::LastChildOf(parent), cx); + return; + } + let insertion_index = below_position.map_or(sheet.cells.len(), |position| column_cells[position].0); + self.update_cell_drop( + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + }, + cx, + ); + } + + /// While dragging near a viewport edge, scroll the board toward it. Driven by a self-rescheduling + /// frame loop so scrolling continues even when the pointer holds still at the edge. + pub(super) fn update_drag_autoscroll(&mut self, pointer: Point, window: &mut Window, cx: &mut Context) { + let bounds = self.board_scroll.bounds(); + if bounds.size.width <= px(1.0) || bounds.size.height <= px(1.0) { + return; + } + const MARGIN: f32 = 56.0; + const MAX_SPEED: f32 = 24.0; + let ramp = |distance: f32| -> f32 { + if distance <= 0.0 { + MAX_SPEED + } else if distance < MARGIN { + MAX_SPEED * (1.0 - distance / MARGIN) + } else { + 0.0 + } + }; + let vx = ramp((pointer.x - bounds.left()).as_f32()) - ramp((bounds.right() - pointer.x).as_f32()); + let vy = ramp((pointer.y - bounds.top()).as_f32()) - ramp((bounds.bottom() - pointer.y).as_f32()); + if vx == 0.0 && vy == 0.0 { + self.drag_autoscroll = None; + return; + } + self.drag_autoscroll = Some(point(px(vx), px(vy))); + self.schedule_drag_autoscroll(window, cx); + } + + fn schedule_drag_autoscroll(&mut self, window: &mut Window, cx: &mut Context) { + if self.drag_autoscroll_scheduled { + return; + } + self.drag_autoscroll_scheduled = true; + cx.on_next_frame(window, |editor, window, cx| { + editor.drag_autoscroll_scheduled = false; + let Some(velocity) = editor.drag_autoscroll else { + return; + }; + if !cx.has_active_drag() || editor.dragging_cell.is_none() { + editor.drag_autoscroll = None; + return; + } + editor.set_user_scroll_offset(editor.board_scroll.offset() + velocity); + cx.notify(); + editor.schedule_drag_autoscroll(window, cx); + }); + } + + pub(super) fn finish_cell_drop(&mut self, dragged: CellId, cx: &mut Context) { + let destination = self.pending_cell_drop.take(); + self.dragging_cell = None; + self.drag_autoscroll = None; + let committed = match (destination, self.active_sheet) { + (Some(destination), Some(sheet_id)) => self.apply_intent( + flowstate_flow::FlowIntent::MoveCellSubtree { + sheet_id, + cell_id: dragged, + drop: destination, + }, + cx, + ), + _ => false, + }; + self.finish_drag_log(destination, committed); + if committed { + self.changed(Some(dragged), cx); + } else { + cx.notify(); + } + } +} diff --git a/crates/flowstate/src/flow/editor/layout.rs b/crates/flowstate/src/flow/editor/layout.rs new file mode 100644 index 00000000..72a35f46 --- /dev/null +++ b/crates/flowstate/src/flow/editor/layout.rs @@ -0,0 +1,182 @@ +use std::collections::HashMap; + +use flowstate_flow::{Cell, CellId, Sheet}; + +pub(super) const CELL_GAP: f32 = 16.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct CellMeasurement { + pub model_height: f32, + screen_height: f32, + zoom: f32, +} + +impl CellMeasurement { + pub fn new(screen_height: f32, zoom: f32) -> Self { + Self { + model_height: screen_height / zoom.max(0.01), + screen_height, + zoom, + } + } + + pub fn update(&mut self, screen_height: f32, zoom: f32) -> bool { + if (self.zoom - zoom).abs() > f32::EPSILON { + self.screen_height = screen_height; + self.zoom = zoom; + return false; + } + if (self.screen_height - screen_height).abs() <= 0.5 { + return false; + } + self.screen_height = screen_height; + self.model_height = screen_height / zoom.max(0.01); + true + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct CellLayout { + pub top: f32, + pub height: f32, +} + +pub(super) fn sheet_cell_layout(sheet: &Sheet, measurements: &HashMap, zoom: f32) -> HashMap { + let cells = sheet + .cells + .iter() + .map(|cell| (cell.id, cell)) + .collect::>(); + let mut children: HashMap> = HashMap::new(); + for cell in &sheet.cells { + if let Some(parent) = cell.parent_id { + children.entry(parent).or_default().push(cell.id); + } + } + let mut layout = HashMap::with_capacity(sheet.cells.len()); + let mut top = 0.0; + for root in sheet.cells.iter().filter(|cell| cell.parent_id.is_none()) { + let family_height = layout_family(root.id, top, &cells, &children, measurements, zoom, &mut layout); + top += family_height + CELL_GAP * zoom; + } + layout +} + +fn layout_family( + cell_id: CellId, + top: f32, + cells: &HashMap, + children: &HashMap>, + measurements: &HashMap, + zoom: f32, + layout: &mut HashMap, +) -> f32 { + let Some(cell) = cells.get(&cell_id) else { + return 0.0; + }; + let cell_height = measured_cell_height(cell, measurements) * zoom; + layout.insert(cell_id, CellLayout { top, height: cell_height }); + + let mut children_top = top; + let mut children_height = 0.0; + for child_id in children.get(&cell_id).into_iter().flatten() { + let child_height = layout_family(*child_id, children_top, cells, children, measurements, zoom, layout); + children_top += child_height + CELL_GAP * zoom; + children_height += child_height + CELL_GAP * zoom; + } + if children_height > 0.0 { + children_height -= CELL_GAP * zoom; + } + cell_height.max(children_height) +} + +fn measured_cell_height(cell: &Cell, measurements: &HashMap) -> f32 { + measurements + .get(&cell.id) + .map(|measurement| measurement.model_height) + .unwrap_or_else(|| estimated_cell_height(cell)) +} + +fn estimated_cell_height(cell: &Cell) -> f32 { + let text = cell.summary.summary_text.to_string(); + let lines = text + .lines() + .map(|line| line.chars().count().div_ceil(34).max(1)) + .sum::() + .max(1); + 32.0 + 22.0 * lines as f32 +} + +#[cfg(test)] +mod tests { + use super::*; + use flowstate_flow::{Cell, ColumnId}; + + fn cell(parent_id: Option) -> Cell { + Cell { + id: CellId::new_v4(), + column_id: ColumnId::new_v4(), + parent_id, + summary: flowstate_flow::CellSummary::default(), + } + } + + #[test] + fn unrelated_roots_occupy_disjoint_global_bands() { + let root = cell(None); + let child_one = cell(Some(root.id)); + let child_two = cell(Some(root.id)); + let orphan = cell(None); + let sheet = Sheet { + id: flowstate_flow::SheetId::new_v4(), + sheet_type_id: flowstate_flow::SheetTypeId::new_v4(), + name: String::new(), + cells: vec![root.clone(), child_one, child_two, orphan.clone()], + annotations: Vec::new(), + }; + + let layout = sheet_cell_layout(&sheet, &HashMap::new(), 1.0); + + assert_eq!(layout[&root.id].top, 0.0); + assert_eq!(layout[&orphan.id].top, 2.0 * 54.0 + 2.0 * CELL_GAP); + } + + #[test] + fn layout_scales_homogeneously_across_zoom_levels() { + let root = cell(None); + let child = cell(Some(root.id)); + let sheet = Sheet { + id: flowstate_flow::SheetId::new_v4(), + sheet_type_id: flowstate_flow::SheetTypeId::new_v4(), + name: String::new(), + cells: vec![root.clone(), child.clone()], + annotations: Vec::new(), + }; + let heights = HashMap::from([(root.id, CellMeasurement::new(80.0, 1.0)), (child.id, CellMeasurement::new(120.0, 1.0))]); + + let small = sheet_cell_layout(&sheet, &heights, 0.25); + let large = sheet_cell_layout(&sheet, &heights, 4.0); + + assert_eq!(large[&root.id].height, small[&root.id].height * 16.0); + assert_eq!(large[&child.id].top, small[&child.id].top * 16.0); + assert_eq!(large[&child.id].height, small[&child.id].height * 16.0); + } + + #[test] + fn zoom_measurements_do_not_redefine_intrinsic_cell_height() { + let mut measurement = CellMeasurement::new(80.0, 1.0); + + assert!(!measurement.update(24.0, 0.25)); + assert_eq!(measurement.model_height, 80.0); + assert!(!measurement.update(322.0, 4.0)); + assert_eq!(measurement.model_height, 80.0); + } + + #[test] + fn same_zoom_content_changes_update_intrinsic_cell_height() { + let mut measurement = CellMeasurement::new(80.0, 1.0); + + assert!(measurement.update(120.0, 1.0)); + assert_eq!(measurement.model_height, 120.0); + } +} diff --git a/crates/flowstate/src/flow/editor/telemetry.rs b/crates/flowstate/src/flow/editor/telemetry.rs new file mode 100644 index 00000000..ffda3815 --- /dev/null +++ b/crates/flowstate/src/flow/editor/telemetry.rs @@ -0,0 +1,234 @@ +//! Env-gated drag-and-drop ergonomics telemetry. +//! +//! When `FLOWSTATE_DRAG_LOG` is set (to a path, or `1`/`true` for a default file), every cell drag is +//! recorded as one JSON object per line: the dragged cell, every zone/intent transition the pointer +//! passed through (with precise per-cell offsets), and the final committed result. Unset = zero cost. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Instant; + +use flowstate_flow::{CellId, FlowDropIntent}; +use gpui::{Bounds, Pixels, Point}; +use gpui_component::PixelsExt as _; +use serde_json::{Value, json}; + +use super::FlowEditor; + +/// In-flight recording for a single drag, flushed as one JSON line when the drag ends. +pub(super) struct DragLogSession { + drag_id: u64, + dragged: String, + sheet: String, + zoom: f32, + started: Instant, + last_key: Option, + samples: Vec, +} + +fn log_path() -> Option<&'static PathBuf> { + static PATH: OnceLock> = OnceLock::new(); + PATH + .get_or_init(|| { + std::env::var_os("FLOWSTATE_DRAG_LOG").and_then(|value| { + let value = value.to_string_lossy(); + match value.trim() { + "" | "0" | "false" => None, + "1" | "true" => Some(PathBuf::from("flow-drag-telemetry.jsonl")), + path => Some(PathBuf::from(path)), + } + }) + }) + .as_ref() +} + +fn enabled() -> bool { + log_path().is_some() +} + +fn append_record(record: &Value) { + let Some(path) = log_path() else { + return; + }; + match serde_json::to_string(record) { + Ok(line) => { + if let Err(error) = append_line(path, &line) { + eprintln!("flow drag telemetry write failed: {error}"); + } + }, + Err(error) => eprintln!("flow drag telemetry serialize failed: {error}"), + } +} + +fn append_line(path: &Path, line: &str) -> std::io::Result<()> { + use std::io::Write as _; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!(file, "{line}") +} + +fn round(value: f32, places: u32) -> f32 { + let scale = 10f32.powi(places as i32); + (value * scale).round() / scale +} + +impl FlowEditor { + pub(super) fn start_drag_log(&mut self, dragged: CellId) { + if !enabled() { + self.drag_log = None; + return; + } + let dragged = self.cell_label(dragged); + let sheet = self + .active_sheet + .and_then(|id| self.board().sheet(id).map(|sheet| sheet.name.clone())) + .unwrap_or_default(); + self.drag_log_counter += 1; + self.drag_log = Some(DragLogSession { + drag_id: self.drag_log_counter, + dragged, + sheet, + zoom: self.board_zoom(), + started: Instant::now(), + last_key: None, + samples: Vec::new(), + }); + } + + pub(super) fn log_drag_over_cell(&mut self, over: CellId, cursor: Point, bounds: Bounds, intent: FlowDropIntent) { + if self.drag_log.is_none() { + return; + } + let over_label = self.cell_label(over); + let intent_str = self.describe_intent(intent); + let valid = self.landing_is_valid(intent); + let width = bounds.size.width.as_f32().max(1.0); + let height = bounds.size.height.as_f32().max(1.0); + let offset_frac = [ + round((cursor.x.as_f32() - bounds.left().as_f32()) / width, 3), + round((cursor.y.as_f32() - bounds.top().as_f32()) / height, 3), + ]; + let key = format!("cell:{over_label}:{intent_str}"); + let over = json!({ "kind": "cell", "label": over_label, "offset_frac": offset_frac }); + self.push_sample(key, over, cursor, intent_str, valid); + } + + pub(super) fn log_drag_over_column(&mut self, column_index: usize, cursor: Point, intent: FlowDropIntent) { + if self.drag_log.is_none() { + return; + } + let intent_str = self.describe_intent(intent); + let valid = self.landing_is_valid(intent); + let key = format!("column:{column_index}:{intent_str}"); + let over = json!({ "kind": "column", "index": column_index }); + self.push_sample(key, over, cursor, intent_str, valid); + } + + pub(super) fn finish_drag_log(&mut self, intent: Option, committed: bool) { + if self.drag_log.is_none() { + return; + } + let intent = intent.map(|intent| self.describe_intent(intent)); + let result = self.sheet_topology_snapshot(); + let Some(session) = self.drag_log.take() else { + return; + }; + let record = json!({ + "drag_id": session.drag_id, + "dragged": session.dragged, + "sheet": session.sheet, + "zoom": session.zoom, + "duration_ms": session.started.elapsed().as_millis() as u64, + "samples": session.samples, + "drop": { "intent": intent, "committed": committed, "result": result }, + }); + append_record(&record); + } + + fn push_sample(&mut self, key: String, over: Value, cursor: Point, intent: String, valid: bool) { + let board_pt = [ + round((cursor.x.as_f32() - self.viewport_origin.x) / self.board_zoom(), 2), + round((cursor.y.as_f32() - self.viewport_origin.y) / self.board_zoom(), 2), + ]; + let Some(session) = self.drag_log.as_mut() else { + return; + }; + if session.last_key.as_deref() == Some(key.as_str()) { + return; + } + let t_ms = session.started.elapsed().as_millis() as u64; + session.last_key = Some(key); + session.samples.push(json!({ + "t_ms": t_ms, + "cursor": [round(cursor.x.as_f32(), 1), round(cursor.y.as_f32(), 1)], + "board_pt": board_pt, + "over": over, + "intent": intent, + "valid_landing": valid, + })); + } + + fn cell_label(&self, id: CellId) -> String { + self + .board() + .cell(id) + .map(|(_, cell)| { + cell + .summary + .summary_text + .lines() + .next() + .unwrap_or_default() + .trim() + .to_string() + }) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| format!("cell:{}", &id.to_string()[..8])) + } + + fn describe_intent(&self, intent: FlowDropIntent) -> String { + match intent { + FlowDropIntent::BeforeSibling(id) => format!("BeforeSibling({})", self.cell_label(id)), + FlowDropIntent::AfterSibling(id) => format!("AfterSibling({})", self.cell_label(id)), + FlowDropIntent::FirstChildOf(id) => format!("FirstChildOf({})", self.cell_label(id)), + FlowDropIntent::LastChildOf(id) => format!("LastChildOf({})", self.cell_label(id)), + FlowDropIntent::RootInColumn { + column_index, + insertion_index, + } => format!("RootInColumn{{col:{column_index}, idx:{insertion_index}}}"), + } + } + + fn landing_is_valid(&self, intent: FlowDropIntent) -> bool { + self + .active_sheet + .zip(self.dragging_cell) + .is_some_and(|(sheet, dragged)| flowstate_flow::board_ops::preview_move_cell_subtree(self.board(), sheet, dragged, intent).is_some()) + } + + fn sheet_topology_snapshot(&self) -> Vec { + let Some(sheet) = self.active_sheet.and_then(|id| self.board().sheet(id)) else { + return Vec::new(); + }; + let definition = self.board().format.sheet_type(sheet.sheet_type_id); + sheet + .cells + .iter() + .map(|cell| { + let column = definition.and_then(|definition| { + definition + .columns + .iter() + .position(|column| column.id == cell.column_id) + }); + json!({ + "label": self.cell_label(cell.id), + "column": column, + "parent": cell.parent_id.map(|parent| self.cell_label(parent)), + }) + }) + .collect() + } +} diff --git a/crates/flowstate/src/flow/editor/zoom.rs b/crates/flowstate/src/flow/editor/zoom.rs new file mode 100644 index 00000000..1a56042f --- /dev/null +++ b/crates/flowstate/src/flow/editor/zoom.rs @@ -0,0 +1,241 @@ +use flowstate_flow::{BoardPoint, BoardRect}; +use gpui::{Pixels, Point, Size, point, px}; +use gpui_component::PixelsExt as _; + +use super::FlowEditor; + +const MIN_ZOOM_PERCENT: f32 = 25.0; +const MAX_ZOOM_PERCENT: f32 = 400.0; +const ZOOM_STEP_PERCENT: f32 = 5.0; + +impl FlowEditor { + pub fn board_zoom(&self) -> f32 { + self.board_zoom + } + + pub fn set_board_zoom(&mut self, zoom: f32, cx: &mut gpui::Context) { + let percent = ((zoom * 100.0 / ZOOM_STEP_PERCENT).round() * ZOOM_STEP_PERCENT).clamp(MIN_ZOOM_PERCENT, MAX_ZOOM_PERCENT); + let zoom = percent / 100.0; + if (self.board_zoom - zoom).abs() < f32::EPSILON { + return; + } + if !self.camera_apply_pending + && self + .camera_center + .is_none_or(|center| !self.camera_center_is_current(center)) + { + self.sync_camera_center_from_scroll(); + } + self.board_zoom = zoom; + self.camera_apply_pending = true; + cx.notify(); + } + + pub(super) fn apply_pending_camera_center(&mut self) { + if !self.camera_apply_pending { + return; + } + if let Some(center) = self.camera_center { + let offset = offset_for_camera_center(center, self.board_scroll.bounds().size, self.board_zoom); + self.board_scroll.set_offset(offset); + } + self.camera_apply_pending = false; + } + + pub(super) fn sync_camera_center_from_scroll(&mut self) { + let viewport = self.board_scroll.bounds().size; + if viewport.width <= px(1.0) || viewport.height <= px(1.0) { + self.camera_center = None; + return; + } + self.camera_center = Some(camera_center_for_offset( + clamp_scroll_offset(self.board_scroll.offset(), self.board_scroll.max_offset()), + viewport, + self.board_zoom, + )); + } + + pub(super) fn set_user_scroll_offset(&mut self, offset: Point) { + self + .board_scroll + .set_offset(clamp_scroll_offset(offset, self.board_scroll.max_offset())); + self.sync_camera_center_from_scroll(); + } + + fn camera_center_is_current(&self, center: BoardPoint) -> bool { + let viewport = self.board_scroll.bounds().size; + if viewport.width <= px(1.0) || viewport.height <= px(1.0) { + return false; + } + camera_center_matches_offset( + center, + self.board_scroll.offset(), + viewport, + self.board_scroll.max_offset(), + self.board_zoom, + px(0.5), + ) + } + + pub fn zoom_percent(&self) -> f32 { + self.board_zoom * 100.0 + } + + pub fn set_zoom_percent(&mut self, percent: f32, cx: &mut gpui::Context) { + self.set_board_zoom(percent / 100.0, cx); + } + + pub fn zoom_in(&mut self, cx: &mut gpui::Context) { + self.set_zoom_percent(self.zoom_percent() + ZOOM_STEP_PERCENT, cx); + } + + pub fn zoom_out(&mut self, cx: &mut gpui::Context) { + self.set_zoom_percent(self.zoom_percent() - ZOOM_STEP_PERCENT, cx); + } + + pub fn visible_board_rect(&self) -> BoardRect { + let bounds = self.board_scroll.bounds(); + let offset = self.board_scroll.offset(); + let scroll_x = -offset.x.as_f32() / self.board_zoom; + let scroll_y = -offset.y.as_f32() / self.board_zoom; + BoardRect { + min: BoardPoint { x: scroll_x, y: scroll_y }, + max: BoardPoint { + x: scroll_x + bounds.size.width.as_f32() / self.board_zoom, + y: scroll_y + bounds.size.height.as_f32() / self.board_zoom, + }, + } + } +} + +fn camera_center_for_offset(offset: Point, viewport: Size, zoom: f32) -> BoardPoint { + BoardPoint { + x: (-offset.x.as_f32() + viewport.width.as_f32() / 2.0) / zoom, + y: (-offset.y.as_f32() + viewport.height.as_f32() / 2.0) / zoom, + } +} + +fn offset_for_camera_center(center: BoardPoint, viewport: Size, zoom: f32) -> Point { + point( + px(-(center.x * zoom - viewport.width.as_f32() / 2.0)), + px(-(center.y * zoom - viewport.height.as_f32() / 2.0)), + ) +} + +fn clamp_scroll_offset(mut offset: Point, max_offset: Size) -> Point { + offset.x = offset.x.clamp(-max_offset.width, px(0.0)); + offset.y = offset.y.clamp(-max_offset.height, px(0.0)); + offset +} + +fn points_are_close(left: Point, right: Point, tolerance: Pixels) -> bool { + (left.x - right.x).abs() <= tolerance && (left.y - right.y).abs() <= tolerance +} + +fn camera_center_matches_offset( + center: BoardPoint, + offset: Point, + viewport: Size, + max_offset: Size, + zoom: f32, + tolerance: Pixels, +) -> bool { + let expected = clamp_scroll_offset(offset_for_camera_center(center, viewport, zoom), max_offset); + points_are_close(expected, offset, tolerance) +} + +pub(super) fn grid_dot_metrics(zoom: f32, device_scale: f32) -> (Pixels, f32) { + let target_size = 1.5 * zoom; + let target_device_size = target_size * device_scale; + if target_device_size < 1.0 { + (px(1.0 / device_scale), target_device_size * target_device_size) + } else { + (px(target_size), 1.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::size; + + #[test] + fn camera_center_round_trips_across_zoom_levels() { + let viewport = size(px(900.0), px(600.0)); + let original = point(px(-1240.0), px(-780.0)); + let center = camera_center_for_offset(original, viewport, 1.0); + + for zoom in [0.25, 2.0, 0.25, 4.0, 1.0] { + let offset = offset_for_camera_center(center, viewport, zoom); + if zoom == 1.0 { + assert_eq!(offset, original); + } + } + } + + #[test] + fn stale_camera_center_is_detected_after_user_scroll() { + let viewport = size(px(900.0), px(600.0)); + let max_offset = size(px(1800.0), px(1200.0)); + let center = camera_center_for_offset(point(px(-400.0), px(-300.0)), viewport, 1.0); + + assert!(camera_center_matches_offset( + center, + point(px(-400.0), px(-300.0)), + viewport, + max_offset, + 1.0, + px(0.5), + )); + assert!(!camera_center_matches_offset( + center, + point(px(-900.0), px(-300.0)), + viewport, + max_offset, + 1.0, + px(0.5), + )); + } + + #[test] + fn clamped_camera_center_remains_valid_for_round_trip_zoom() { + let viewport = size(px(900.0), px(600.0)); + let center = BoardPoint { x: 1400.0, y: 900.0 }; + + assert!(camera_center_matches_offset( + center, + point(px(0.0), px(0.0)), + viewport, + size(px(0.0), px(0.0)), + 0.25, + px(0.5), + )); + } + + #[test] + fn camera_center_is_invalidated_when_viewport_geometry_changes() { + let original_viewport = size(px(900.0), px(600.0)); + let offset = point(px(-400.0), px(-300.0)); + let center = camera_center_for_offset(offset, original_viewport, 1.0); + + assert!(!camera_center_matches_offset( + center, + offset, + size(px(1100.0), px(600.0)), + size(px(1800.0), px(1200.0)), + 1.0, + px(0.5), + )); + } + + #[test] + fn subpixel_grid_dots_fade_instead_of_becoming_heavier() { + let (small_size, small_opacity) = grid_dot_metrics(0.25, 1.0); + let (normal_size, normal_opacity) = grid_dot_metrics(1.0, 1.0); + + assert_eq!(small_size, px(1.0)); + assert!(small_opacity < normal_opacity); + assert_eq!(normal_size, px(1.5)); + assert_eq!(normal_opacity, 1.0); + } +} diff --git a/crates/flowstate/src/flow/mod.rs b/crates/flowstate/src/flow/mod.rs index e983a964..d2992962 100644 --- a/crates/flowstate/src/flow/mod.rs +++ b/crates/flowstate/src/flow/mod.rs @@ -1,7 +1,36 @@ +mod cell_theme; pub mod editor; pub mod panel; pub mod ribbon; -pub use editor::FlowEditor; +pub use editor::{AnnotationTool, FlowEditor, FlowEditorEvent, FlowExternalPresence}; pub use panel::FlowPanel; pub use ribbon::FlowRibbon; + +use gpui::{App, Hsla}; +use gpui_component::ActiveTheme as _; + +#[derive(Clone, Copy)] +pub(crate) struct FlowSidePalette { + pub base: Hsla, + pub foreground: Hsla, + pub hover: Hsla, + pub active: Hsla, +} + +pub(crate) fn flow_side_palette(side: flowstate_flow::ArgumentSide, cx: &App) -> FlowSidePalette { + match side { + flowstate_flow::ArgumentSide::One => FlowSidePalette { + base: cx.theme().primary, + foreground: cx.theme().primary_foreground, + hover: cx.theme().primary_hover, + active: cx.theme().primary_active, + }, + flowstate_flow::ArgumentSide::Two => FlowSidePalette { + base: cx.theme().info, + foreground: cx.theme().info_foreground, + hover: cx.theme().info_hover, + active: cx.theme().info_active, + }, + } +} diff --git a/crates/flowstate/src/flow/panel.rs b/crates/flowstate/src/flow/panel.rs index 8162a064..fb51b1cf 100644 --- a/crates/flowstate/src/flow/panel.rs +++ b/crates/flowstate/src/flow/panel.rs @@ -1,10 +1,9 @@ use std::path::PathBuf; use gpui::{App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, Render, SharedString, WeakEntity, Window, div, prelude::*}; -use gpui_component::ActiveTheme as _; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{Panel, PanelControl, PanelEvent, PanelInfo, PanelState}; -use gpui_component::{IconName, Sizable}; +use gpui_component::{ActiveTheme as _, IconName, Sizable}; use serde_json::json; use uuid::Uuid; @@ -22,7 +21,6 @@ pub struct FlowPanel { active: bool, } -#[hotpath::measure_all] impl FlowPanel { pub fn new_with_title( title: Option, @@ -83,7 +81,6 @@ impl FlowPanel { } } -#[hotpath::measure] fn title_for_path(path: Option<&PathBuf>) -> SharedString { path .and_then(|path| path.file_name()) @@ -94,30 +91,25 @@ fn title_for_path(path: Option<&PathBuf>) -> SharedString { impl EventEmitter for FlowPanel {} -#[hotpath::measure_all] impl Focusable for FlowPanel { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } -#[hotpath::measure_all] impl Panel for FlowPanel { fn panel_name(&self) -> &'static str { "FlowPanel" } - #[hotpath::measure] fn tab_name(&self, cx: &App) -> Option { Some(self.display_title(cx)) } - #[hotpath::measure] fn title(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { self.display_title(cx).clone() } - #[hotpath::measure] fn title_suffix(&mut self, _: &mut Window, _cx: &mut Context) -> Option { let workspace = self.workspace.clone(); let panel_id = self.id; @@ -133,17 +125,14 @@ impl Panel for FlowPanel { ) } - #[hotpath::measure] fn closable(&self, _: &App) -> bool { false } - #[hotpath::measure] fn zoomable(&self, _: &App) -> Option { Some(PanelControl::Both) } - #[hotpath::measure] fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context) { self.active = active; if active { @@ -160,7 +149,6 @@ impl Panel for FlowPanel { } } - #[hotpath::measure] fn on_removed(&mut self, window: &mut Window, cx: &mut Context) { let panel_id = self.id; let workspace = self.workspace.clone(); @@ -171,7 +159,6 @@ impl Panel for FlowPanel { }); } - #[hotpath::measure] fn dump(&self, _: &App) -> PanelState { PanelState { panel_name: self.panel_name().to_string(), @@ -184,13 +171,11 @@ impl Panel for FlowPanel { } } - #[hotpath::measure] fn inner_padding(&self, _: &App) -> bool { false } } -#[hotpath::measure_all] impl Render for FlowPanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() diff --git a/crates/flowstate/src/flow/ribbon.rs b/crates/flowstate/src/flow/ribbon.rs index ebc1296d..a096e308 100644 --- a/crates/flowstate/src/flow/ribbon.rs +++ b/crates/flowstate/src/flow/ribbon.rs @@ -1,468 +1,262 @@ -use flowstate_flow::{DebateStyleKey, FormatKind, all_debate_style_templates}; +use flowstate_flow::RelativePosition; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Subscription, - Window, div, prelude::*, px, + App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, MouseButton, MouseDownEvent, Render, Subscription, Window, div, + prelude::*, px, }; -use gpui_component::button::{Button, ButtonVariants, Toggle}; +use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::select::{SearchableVec, Select, SelectEvent, SelectState}; -use gpui_component::{ActiveTheme as _, Disableable, Icon, IconName, PixelsExt, Selectable, Sizable, StyledExt, h_flex, v_flex}; +use gpui_component::{ActiveTheme as _, Disableable as _, Selectable as _, Sizable as _}; -use crate::flow::FlowEditor; - -type DebateStyleSelectDelegate = SearchableVec; +use crate::flow::{AnnotationTool, FlowEditor}; pub struct FlowRibbon { editor: Entity, focus_handle: FocusHandle, height: gpui::Pixels, - style_select: Entity>, - title_input: Entity, - syncing_title: bool, - _subscriptions: Vec, } -#[hotpath::measure_all] -impl FlowRibbon { - pub fn new(editor: Entity, window: &mut Window, cx: &mut Context) -> Self { - let style_labels = all_debate_style_templates() - .iter() - .map(|template| SharedString::from(template.label)) - .collect::>(); - let style_select = cx.new(|cx| { - let mut select = SelectState::new(SearchableVec::new(style_labels), None, window, cx).searchable(true); - select.set_selected_value(&SharedString::from(editor.read(cx).selected_style_label()), window, cx); - select - }); - let title_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("flow name") - .default_value(editor.read(cx).selected_flow_title()) - }); - let style_editor = editor.clone(); - let style_subscription = cx.subscribe_in( - &style_select, - window, - move |_, _, event: &SelectEvent, _, cx| { - if let SelectEvent::Confirm(Some(label)) = event - && let Some(style) = style_key_for_label(label) - { - style_editor.update(cx, |editor, cx| editor.set_selected_style(style, cx)); - } - }, - ); - let title_editor = editor.clone(); - let title_subscription = cx.subscribe(&title_input, move |ribbon, input, event: &InputEvent, cx| match event { - InputEvent::Change => { - if ribbon.syncing_title { - return; - } - let value = input.read(cx).value().to_string(); - title_editor.update(cx, |editor, cx| editor.set_selected_flow_title(value, cx)); - }, - InputEvent::Blur => { - title_editor.update(cx, |editor, cx| editor.resolve_pending(cx)); - }, - InputEvent::Focus | InputEvent::PressEnter { .. } | InputEvent::BackspaceEmpty | InputEvent::DeleteEmpty => {}, - }); +struct SheetNameInputState { + input: Entity, + _subscription: Subscription, +} +impl FlowRibbon { + pub fn new(editor: Entity, _window: &mut Window, cx: &mut Context) -> Self { Self { editor, focus_handle: cx.focus_handle(), - height: default_flow_ribbon_height(), - style_select, - title_input, - syncing_title: false, - _subscriptions: vec![style_subscription, title_subscription], + height: px(112.0), } } + pub fn editor(&self) -> Entity { + self.editor.clone() + } + pub fn set_height(&mut self, height: gpui::Pixels, cx: &mut Context) { if self.height != height { self.height = height; cx.notify(); } } - - fn sync_controls(&mut self, window: &mut Window, cx: &mut Context) { - let style_label = SharedString::from(self.editor.read(cx).selected_style_label()); - if self - .style_select - .read(cx) - .selected_value() - .is_none_or(|selected| selected != &style_label) - { - self - .style_select - .update(cx, |select, cx| select.set_selected_value(&style_label, window, cx)); - } - - let title = self.editor.read(cx).selected_flow_title(); - if self.title_input.read(cx).value().as_ref() != title { - self.syncing_title = true; - self - .title_input - .update(cx, |input, cx| input.set_value(title, window, cx)); - self.syncing_title = false; - } - } - - fn render_setup_group(&mut self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - let editor = self.editor.clone(); - let selected_style = editor.read(cx).selected_style(); - let ld_toc = editor.read(cx).ld_toc_circuit(); - let has_switch = editor.read(cx).has_switchable_templates(); - let switch_speakers = editor.read(cx).switch_speakers(); - let templates = editor.read(cx).templates(); - - let mut controls = Vec::::new(); - controls.push( - div() - .w(px(164.0)) - .child( - Select::new(&self.style_select) - .placeholder("Debate style") - .search_placeholder("Search styles") - .w_full(), - ) - .into_any_element(), - ); - if selected_style == DebateStyleKey::LincolnDouglas { - controls.push( - Toggle::new("flow-ribbon-ld-toc") - .label("TOC") - .xsmall() - .checked(ld_toc) - .on_click({ - let editor = editor.clone(); - move |_, _, cx| { - editor.update(cx, |editor, cx| editor.toggle_ld_toc_circuit(cx)); - } - }) - .into_any_element(), - ); - } - if has_switch { - controls.push( - Toggle::new("flow-ribbon-switch-speakers") - .label("Switch") - .xsmall() - .checked(switch_speakers) - .on_click({ - let editor = editor.clone(); - move |_, _, cx| { - editor.update(cx, |editor, cx| editor.toggle_switch_speakers(cx)); - } - }) - .into_any_element(), - ); - } - controls.extend(templates.into_iter().enumerate().map(|(ix, template)| { - let label = template.name.clone(); - flow_chip(("flow-ribbon-add-template", ix), metrics, cx) - .icon(IconName::Plus) - .label(label) - .tooltip("Add flow") - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_flow(template.clone(), window, cx)); - } - }) - .into_any_element() - })); - flow_ribbon_group("Setup", controls, metrics, cx) - } - - fn render_title_group(&mut self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - flow_ribbon_group( - "Flow", - vec![ - div() - .w(px(220.0)) - .child( - Input::new(&self.title_input) - .appearance(false) - .bordered(true) - .focus_bordered(true) - .w_full(), - ) - .into_any_element(), - flow_chip("flow-ribbon-delete-flow", metrics, cx) - .icon(IconName::Delete) - .danger() - .tooltip("Delete flow") - .disabled(!self.editor.read(cx).command_state().has_flow) - .on_click({ - let editor = self.editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.delete_selected_flow(window, cx)); - } - }) - .into_any_element(), - ], - metrics, - cx, - ) - } - - fn render_edit_group(&self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - let state = self.editor.read(cx).command_state(); - let editor = self.editor.clone(); - flow_ribbon_group( - "Edit", - vec![ - flow_chip("flow-ribbon-undo", metrics, cx) - .icon(IconName::Undo) - .tooltip("Undo") - .disabled(!state.can_undo) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.undo_selected(window, cx)); - } - }), - flow_chip("flow-ribbon-redo", metrics, cx) - .icon(IconName::Redo) - .tooltip("Redo") - .disabled(!state.can_redo) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.redo_selected(window, cx)); - } - }), - flow_chip("flow-ribbon-add-child", metrics, cx) - .icon(IconName::ArrowRight) - .tooltip("Add response") - .disabled(!state.has_selected_box) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_child_to_focus(window, cx)); - } - }), - flow_chip("flow-ribbon-add-above", metrics, cx) - .icon(IconName::ArrowUp) - .tooltip("Add above") - .disabled(!state.has_selected_box) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_sibling_to_focus(0, window, cx)); - } - }), - flow_chip("flow-ribbon-add-below", metrics, cx) - .icon(IconName::ArrowDown) - .tooltip("Add below") - .disabled(!state.has_selected_box) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.add_sibling_to_focus(1, window, cx)); - } - }), - flow_chip("flow-ribbon-extend", metrics, cx) - .icon(IconName::ArrowRight) - .tooltip("Extend") - .disabled(!state.can_format) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.extend_focus(window, cx)); - } - }), - flow_chip("flow-ribbon-delete-box", metrics, cx) - .icon(IconName::Delete) - .tooltip("Delete selected") - .disabled(!state.has_selected_box) - .on_click(move |_, window, cx| { - editor.update(cx, |editor, cx| editor.delete_focus(window, cx)); - }), - ] - .into_iter() - .map(IntoElement::into_any_element) - .collect(), - metrics, - cx, - ) - } - - fn render_format_group(&self, metrics: FlowRibbonMetrics, cx: &mut Context) -> impl IntoElement { - let state = self.editor.read(cx).command_state(); - let editor = self.editor.clone(); - flow_ribbon_group( - "Format", - vec![ - flow_chip("flow-ribbon-bold", metrics, cx) - .child(Icon::default().path("icons/bold.svg").xsmall()) - .tooltip("Bold") - .disabled(!state.can_format) - .selected(state.selected_bold) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.toggle_format_focus(FormatKind::Bold, window, cx)); - } - }), - flow_chip("flow-ribbon-cross", metrics, cx) - .child(Icon::default().path("icons/strikethrough.svg").xsmall()) - .tooltip("Cross out") - .disabled(!state.can_format) - .selected(state.selected_crossed) - .on_click({ - let editor = editor.clone(); - move |_, window, cx| { - editor.update(cx, |editor, cx| editor.toggle_format_focus(FormatKind::Crossed, window, cx)); - } - }), - flow_chip("flow-ribbon-fold", metrics, cx) - .icon(IconName::PanelRightClose) - .tooltip("Fold") - .disabled(!state.can_fold) - .selected(state.selected_folded) - .on_click(move |_, _, cx| { - editor.update(cx, |editor, cx| editor.toggle_fold_focus(cx)); - }), - ] - .into_iter() - .map(IntoElement::into_any_element) - .collect(), - metrics, - cx, - ) - } } impl EventEmitter<()> for FlowRibbon {} -#[hotpath::measure_all] impl Focusable for FlowRibbon { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } -#[hotpath::measure_all] impl Render for FlowRibbon { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.sync_controls(window, cx); - let metrics = FlowRibbonMetrics::from_height(self.height); + let editor = self.editor.clone(); + let undo_editor = editor.clone(); + let redo_editor = editor.clone(); + let response_editor = editor.clone(); + let first_argument_editor = editor.clone(); + let previous_sheet_editor = editor.clone(); + let next_sheet_editor = editor.clone(); + let delete_sheet_editor = editor.clone(); + let above_editor = editor.clone(); + let below_editor = editor.clone(); + let delete_editor = editor.clone(); + let strike_editor = editor; + let marker_editor = self.editor.clone(); + let eraser_editor = self.editor.clone(); + let visibility_editor = self.editor.clone(); + let clear_editor = self.editor.clone(); + let has_active_sheet = self.editor.read(cx).active_sheet().is_some(); + let has_active_cell = self.editor.read(cx).active_cell().is_some(); + #[allow( + clippy::needless_collect, + reason = "Release the editor read guard before constructing buttons that clone the editor entity." + )] + let sheet_types: Vec<_> = self + .editor + .read(cx) + .board() + .format + .sheet_types + .iter() + .map(|sheet_type| sheet_type.name.clone()) + .collect(); + let sheet_name_input = self.editor.read(cx).active_sheet().and_then(|sheet_id| { + let sheet_name = self.editor.read(cx).board().sheet(sheet_id)?.name.clone(); + let editor = self.editor.clone(); + let state = window.use_keyed_state(("flow-sheet-name-input", sheet_id.as_u128() as u64), cx, move |window, cx| { + let input = cx.new(|cx| { + InputState::new(window, cx) + .default_value(sheet_name) + .placeholder("Sheet name") + }); + let subscription = cx.subscribe_in(&input, window, move |_: &mut SheetNameInputState, input, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Change) { + let name = input.read(cx).value().to_string(); + editor.update(cx, |editor, cx| editor.rename_active_sheet(name, cx)); + } + }); + SheetNameInputState { + input, + _subscription: subscription, + } + }); + Some(state.read(cx).input.clone()) + }); div() .w_full() - .h(metrics.height) - .min_h(min_flow_ribbon_height()) - .px(metrics.outer_padding_x) + .h(self.height) + .flex() + .items_center() + .gap(px(8.0)) + .px(px(12.0)) + .bg(cx.theme().secondary) + .on_mouse_down(MouseButton::Left, { + let editor = self.editor.clone(); + move |_: &MouseDownEvent, _, cx| editor.update(cx, |editor, cx| editor.set_annotation_tool(AnnotationTool::None, cx)) + }) + .when_some(sheet_name_input, |this, input| { + this.child(div().w(px(180.0)).child(Input::new(&input).w_full())) + }) .child( - h_flex() - .w_full() - .h_full() - .items_start() - .gap_2() - .bg(cx.theme().background) - .px(metrics.inner_padding_x) - .pt(metrics.group_padding_top) - .children([ - self.render_setup_group(metrics, cx).into_any_element(), - self.render_title_group(metrics, cx).into_any_element(), - self.render_edit_group(metrics, cx).into_any_element(), - self.render_format_group(metrics, cx).into_any_element(), - ]), + Button::new("flow-undo") + .label("Undo") + .small() + .disabled(!self.editor.read(cx).can_undo()) + .on_click(move |_, _, cx| undo_editor.update(cx, |editor, cx| editor.undo(cx))), + ) + .child( + Button::new("flow-redo") + .label("Redo") + .small() + .disabled(!self.editor.read(cx).can_redo()) + .on_click(move |_, _, cx| redo_editor.update(cx, |editor, cx| editor.redo(cx))), + ) + .children(sheet_types.into_iter().enumerate().map(|(index, name)| { + let editor = self.editor.clone(); + Button::new(("flow-create-sheet-type", index)) + .label(format!("New {name}")) + .small() + .on_click(move |_, _, cx| editor.update(cx, |editor, cx| editor.create_sheet_of_type(index, cx))) + })) + .child( + Button::new("flow-move-sheet-left") + .label("Move sheet left") + .small() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| previous_sheet_editor.update(cx, |editor, cx| editor.move_active_sheet(-1, cx))), + ) + .child( + Button::new("flow-move-sheet-right") + .label("Move sheet right") + .small() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| next_sheet_editor.update(cx, |editor, cx| editor.move_active_sheet(1, cx))), + ) + .child( + Button::new("flow-delete-sheet") + .label("Delete sheet") + .small() + .danger() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| delete_sheet_editor.update(cx, |editor, cx| editor.delete_active_sheet(cx))), + ) + .child( + Button::new("flow-add-first-argument") + .label("Add argument") + .small() + .disabled(!has_active_sheet) + .on_click(move |_, window, cx| { + first_argument_editor.update(cx, |editor, cx| { + editor.add_first_argument(cx); + editor.focus_active_cell(window, cx); + }); + }), + ) + .child( + Button::new("flow-add-response") + .label("Add response") + .small() + .disabled(!has_active_cell) + .on_click(move |_, window, cx| { + response_editor.update(cx, |editor, cx| { + editor.add_response(cx); + editor.focus_active_cell(window, cx); + }); + }), + ) + .child( + Button::new("flow-add-sibling-above") + .label("Sibling above") + .small() + .disabled(!has_active_cell) + .on_click(move |_, window, cx| { + above_editor.update(cx, |editor, cx| { + editor.add_sibling(RelativePosition::Before, cx); + editor.focus_active_cell(window, cx); + }); + }), + ) + .child( + Button::new("flow-add-sibling") + .label("Add sibling") + .small() + .disabled(!has_active_cell) + .on_click(move |_, window, cx| { + below_editor.update(cx, |editor, cx| { + editor.add_sibling(RelativePosition::After, cx); + editor.focus_active_cell(window, cx); + }); + }), + ) + .child( + Button::new("flow-delete-selected") + .label("Delete selected") + .small() + .danger() + .disabled(!has_active_cell) + .on_click(move |_, window, cx| delete_editor.update(cx, |editor, cx| editor.delete_selected(window, cx))), + ) + .child( + Button::new("flow-strike-selected") + .label("Strike") + .small() + .selected(self.editor.read(cx).active_cell_is_struck()) + .disabled(!has_active_cell) + .on_click(move |_, _, cx| strike_editor.update(cx, |editor, cx| editor.strike_selected(cx))), + ) + .child( + Button::new("flow-arm-marker") + .label("Marker") + .small() + .selected(self.editor.read(cx).annotation_tool() == AnnotationTool::Marker) + .on_click(move |_, _, cx| marker_editor.update(cx, |editor, cx| editor.toggle_annotation_tool(AnnotationTool::Marker, cx))), + ) + .child( + Button::new("flow-arm-eraser") + .label("Eraser") + .small() + .selected(self.editor.read(cx).annotation_tool() == AnnotationTool::Eraser) + .on_click(move |_, _, cx| eraser_editor.update(cx, |editor, cx| editor.toggle_annotation_tool(AnnotationTool::Eraser, cx))), + ) + .child( + Button::new("flow-toggle-annotations") + .label(if self.editor.read(cx).annotations_visible() { + "Hide annotations" + } else { + "Show annotations" + }) + .small() + .on_click(move |_, _, cx| visibility_editor.update(cx, |editor, cx| editor.toggle_annotations_visible(cx))), + ) + .child( + Button::new("flow-clear-annotations") + .label("Clear annotations") + .small() + .danger() + .disabled(!has_active_sheet) + .on_click(move |_, _, cx| clear_editor.update(cx, |editor, cx| editor.clear_annotations(cx))), ) } } - -#[derive(Clone, Copy)] -struct FlowRibbonMetrics { - height: gpui::Pixels, - chip_height: gpui::Pixels, - chip_padding_x: gpui::Pixels, - chip_gap: gpui::Pixels, - group_gap: gpui::Pixels, - group_padding_top: gpui::Pixels, - outer_padding_x: gpui::Pixels, - inner_padding_x: gpui::Pixels, -} - -#[hotpath::measure_all] -impl FlowRibbonMetrics { - fn from_height(height: gpui::Pixels) -> Self { - let height = px( - height - .as_f32() - .clamp(min_flow_ribbon_height().as_f32(), max_flow_ribbon_height().as_f32()), - ); - let scale = ((height.as_f32() - min_flow_ribbon_height().as_f32()) - / (max_flow_ribbon_height().as_f32() - min_flow_ribbon_height().as_f32())) - .clamp(0.0, 1.0); - Self { - height, - chip_height: px(20.0 + 10.0 * scale), - chip_padding_x: px(3.0 + 7.0 * scale), - chip_gap: px(2.0 + 4.0 * scale), - group_gap: px(4.0 + 7.0 * scale), - group_padding_top: px(3.0 + 3.0 * scale), - outer_padding_x: px(8.0), - inner_padding_x: px(8.0), - } - } -} - -#[hotpath::measure] -fn flow_ribbon_group(label: &'static str, controls: Vec, metrics: FlowRibbonMetrics, cx: &mut App) -> impl IntoElement { - v_flex() - .flex_none() - .gap_0p5() - .pr(metrics.group_gap) - .border_r_1() - .border_color(cx.theme().border.opacity(0.72)) - .child( - div() - .text_size(px(10.0)) - .font_medium() - .text_color(cx.theme().muted_foreground) - .child(label), - ) - .child( - h_flex() - .items_center() - .content_start() - .gap(metrics.chip_gap) - .flex_wrap() - .children(controls), - ) -} - -#[hotpath::measure] -fn flow_chip(id: impl Into, metrics: FlowRibbonMetrics, _cx: &mut App) -> Button { - Button::new(id) - .xsmall() - .compact() - .outline() - .h(metrics.chip_height) - .px(metrics.chip_padding_x) - .rounded(px(6.0)) -} - -#[hotpath::measure] -fn style_key_for_label(label: &SharedString) -> Option { - all_debate_style_templates() - .into_iter() - .find(|template| template.label == label.as_ref()) - .map(|template| template.key) -} - -#[hotpath::measure] -fn default_flow_ribbon_height() -> gpui::Pixels { - px(112.0) -} - -#[hotpath::measure] -fn min_flow_ribbon_height() -> gpui::Pixels { - px(56.0) -} - -#[hotpath::measure] -fn max_flow_ribbon_height() -> gpui::Pixels { - px(158.0) -} diff --git a/crates/flowstate/src/main.rs b/crates/flowstate/src/main.rs index 6a50e0bc..379a934d 100644 --- a/crates/flowstate/src/main.rs +++ b/crates/flowstate/src/main.rs @@ -18,19 +18,32 @@ impl Default for FlowstateAllocator { } } -// SAFETY: This allocator delegates all allocation operations directly to -// mimalloc's `GlobalAlloc` implementation without changing the pointer, -// layout, or ownership contracts. +// SAFETY: this implementation delegates every allocation and deallocation to a +// process-wide allocator implementation that satisfies `GlobalAlloc`. unsafe impl GlobalAlloc for FlowstateAllocator { + #[cfg(not(windows))] unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - // SAFETY: The caller upholds `GlobalAlloc::alloc`'s layout contract, and - // the layout is forwarded unchanged to mimalloc. + // SAFETY: `GlobalAlloc::alloc` forwards the caller-provided layout unchanged + // to the selected global allocator. + unsafe { rimalloc::Rimalloc.alloc(layout) } + } + #[cfg(windows)] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: `GlobalAlloc::alloc` forwards the caller-provided layout unchanged + // to the selected global allocator. unsafe { mimalloc::MiMalloc.alloc(layout) } } + #[cfg(not(windows))] + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: callers of `GlobalAlloc::dealloc` must provide a pointer/layout + // pair from this allocator; we forward that pair unchanged. + unsafe { rimalloc::Rimalloc.dealloc(ptr, layout) } + } + #[cfg(windows)] unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - // SAFETY: The caller guarantees `ptr` and `layout` came from this - // allocator; both are forwarded unchanged to mimalloc. + // SAFETY: callers of `GlobalAlloc::dealloc` must provide a pointer/layout + // pair from this allocator; we forward that pair unchanged. unsafe { mimalloc::MiMalloc.dealloc(ptr, layout) } } } diff --git a/crates/flowstate/src/ribbon/editor_ribbon/core.rs b/crates/flowstate/src/ribbon/editor_ribbon/core.rs index d98dee86..28ef459b 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/core.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/core.rs @@ -1,6 +1,6 @@ use gpui::{ - AnyElement, App, Context, Entity, Focusable, Hsla, IntoElement, Keystroke, ParentElement as _, Render, Styled as _, WeakEntity, Window, div, - prelude::*, px, relative, + Action, AnyElement, App, Context, Entity, Focusable, Hsla, IntoElement, Keystroke, ParentElement as _, Render, Styled as _, WeakEntity, Window, + div, prelude::*, px, relative, }; use gpui_component::Size; use gpui_component::button::DropdownButton; @@ -11,7 +11,7 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::{ActiveTheme as _, Disableable as _, Icon, IconName, PixelsExt as _, Selectable as _, Sizable as _}; use serde::{Deserialize, Serialize}; -use crate::commands::{CommandId, active_first_key_for}; +use crate::commands::{CommandId, action_for_command, active_first_key_for, context_for}; use crate::ribbon::style_catalog::{HIGHLIGHT_STYLE_SPECS, PARAGRAPH_STYLE_SPECS, SEMANTIC_STYLE_SPECS}; use crate::workspace::Workspace; use uuid::Uuid; diff --git a/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs b/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs index f6f0f0eb..ab586d92 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/helpers.rs @@ -26,18 +26,6 @@ fn show_shortcut(options: ModernRibbonOptions) -> bool { } } -#[hotpath::measure] -fn command_tooltip(command: &RibbonCommand) -> String { - let label = match command.id { - RibbonCommandId::SendToSpeechDocument | RibbonCommandId::SendToSpeechDocumentEnd => "Send to speech", - _ => command.label, - }; - match &command.shortcut { - Some(shortcut) => format!("{label} ({shortcut})"), - None => label.to_string(), - } -} - #[hotpath::measure] fn keycap(shortcut: String, cx: &mut Context) -> AnyElement { div() diff --git a/crates/flowstate/src/ribbon/editor_ribbon/menus.rs b/crates/flowstate/src/ribbon/editor_ribbon/menus.rs index c3cc9d21..ac89367f 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/menus.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/menus.rs @@ -120,6 +120,11 @@ fn modern_condense_menu( let label = RibbonLabel::for_command(command); let command_color = ribbon_command_color(command, cx); let shortcut = command.shortcut.clone(); + let condense_action = command + .command_id + .and_then(action_for_command) + .map(|action| (action, command.command_id.and_then(context_for))); + let has_condense_action = condense_action.is_some(); DropdownButton::new("modern-ribbon-condense-dropdown") .with_size(Size::Size(chip_height)) @@ -131,7 +136,10 @@ fn modern_condense_menu( .ghost() .h(chip_height) .px(metrics.chip_padding_x) - .tooltip(command_tooltip(command)) + .when_some(condense_action, |this, (action_box, ctx)| { + this.tooltip_with_action(command.label, &*action_box, ctx) + }) + .when(!has_condense_action, |this| this.tooltip(command.label)) .when_some(label.icon_path, |this, path| { this.child( Icon::default() @@ -186,6 +194,7 @@ fn modern_condensed_menu( command: &RibbonCommand, editor: Entity, metrics: RibbonLayoutMetrics, + options: ModernRibbonOptions, cx: &mut Context, ) -> AnyElement { let mode_active = command.selected; @@ -193,6 +202,7 @@ fn modern_condensed_menu( let chip_height = metrics.chip_height; let label = RibbonLabel::for_command(command); let command_color = ribbon_command_color(command, cx); + let shortcut = command.shortcut.clone(); DropdownButton::new("modern-ribbon-condensed-dropdown") .with_size(Size::Size(chip_height)) @@ -231,6 +241,9 @@ fn modern_condensed_menu( .child(label.text), ) }) + .when(show_shortcut(options), |this| { + this.when_some(shortcut, |this, shortcut| this.child(keycap(shortcut, cx))) + }) .on_click({ let editor = editor.clone(); move |_, window, cx| { @@ -517,12 +530,18 @@ fn modern_undo_button( cx: &mut Context, ) -> AnyElement { let enabled = editor.read(cx).can_undo(); - let tooltip = shortcut_for(CommandId::Undo) - .map(|s| format!("Undo ({s})")) - .unwrap_or_else(|| "Undo".to_string()); - modern_icon_chip(IconName::Undo, tooltip, command, editor, metrics, cx, enabled, |editor, cx| { - editor.undo(cx); - }) + let undo_action = action_for_command(CommandId::Undo).map(|a| (a, context_for(CommandId::Undo))); + modern_icon_chip( + IconName::Undo, + "Undo", + undo_action, + command, + editor, + metrics, + cx, + enabled, + |editor, cx| editor.undo(cx), + ) } #[hotpath::measure] @@ -533,17 +552,24 @@ fn modern_redo_button( cx: &mut Context, ) -> AnyElement { let enabled = editor.read(cx).can_redo(); - let tooltip = shortcut_for(CommandId::Redo) - .map(|s| format!("Redo ({s})")) - .unwrap_or_else(|| "Redo".to_string()); - modern_icon_chip(IconName::Redo, tooltip, command, editor, metrics, cx, enabled, |editor, cx| { - editor.redo(cx); - }) + let redo_action = action_for_command(CommandId::Redo).map(|a| (a, context_for(CommandId::Redo))); + modern_icon_chip( + IconName::Redo, + "Redo", + redo_action, + command, + editor, + metrics, + cx, + enabled, + |editor, cx| editor.redo(cx), + ) } fn modern_icon_chip( icon: IconName, - tooltip: String, + tooltip: &'static str, + action_with_ctx: Option<(Box, Option<&'static str>)>, command: &RibbonCommand, editor: Entity, metrics: RibbonLayoutMetrics, @@ -557,6 +583,7 @@ fn modern_icon_chip( } else { cx.theme().muted_foreground.opacity(0.5) }; + let has_tooltip_action = action_with_ctx.is_some(); Button::new(("modern-ribbon-command", ribbon_command_key(command.id))) .xsmall() .compact() @@ -565,7 +592,10 @@ fn modern_icon_chip( .w(metrics.chip_height) .px(metrics.chip_padding_x) .icon(Icon::new(icon).xsmall().text_color(icon_color)) - .tooltip(tooltip) + .when_some(action_with_ctx, |this, (action_box, ctx)| { + this.tooltip_with_action(tooltip, &*action_box, ctx) + }) + .when(!has_tooltip_action, |this| this.tooltip(tooltip)) .disabled(!enabled) .on_click(move |_, _, cx| { editor.update(cx, |editor, cx| action(editor, cx)); diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs index 32db4419..d5859cdc 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_layout.rs @@ -41,7 +41,7 @@ fn modern_group( } else if matches!(command.id, RibbonCommandId::CondenseMenu) { modern_condense_menu(command, editor.clone(), metrics, options, cx) } else if matches!(command.id, RibbonCommandId::CondensedMenu) { - modern_condensed_menu(command, editor.clone(), metrics, cx) + modern_condensed_menu(command, editor.clone(), metrics, options, cx) } else if matches!(command.id, RibbonCommandId::Undo) { modern_undo_button(command, editor.clone(), metrics, cx) } else if matches!(command.id, RibbonCommandId::Redo) { @@ -195,12 +195,23 @@ fn modern_command_chip( cx: &mut Context, ) -> AnyElement { let command_id = command.id; - let tooltip = command_tooltip(command); let shortcut = command.shortcut.clone(); let label = RibbonLabel::for_command(command); let icon_path = label.prefers_icon().then_some(label.icon_path).flatten(); let command_color = ribbon_command_color(command, cx); + let tooltip_label = match command.id { + RibbonCommandId::SendToSpeechDocument | RibbonCommandId::SendToSpeechDocumentEnd => { + "Send to speech" + } + _ => command.label, + }; + let tooltip_action = command + .command_id + .and_then(action_for_command) + .map(|action| (action, command.command_id.and_then(context_for))); + let has_tooltip_action = tooltip_action.is_some(); + Button::new(("modern-ribbon-command", ribbon_command_key(command_id))) .xsmall() .compact() @@ -212,7 +223,10 @@ fn modern_command_chip( .selected(command.selected) .disabled(command.disabled) .text_color(command_color) - .tooltip(tooltip) + .when_some(tooltip_action, |this, (action_box, ctx)| { + this.tooltip_with_action(tooltip_label, &*action_box, ctx) + }) + .when(!has_tooltip_action, |this| this.tooltip(tooltip_label)) .when(command.selected, |this| { this .border_color(command_color) diff --git a/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs b/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs index 0cf49e48..b41fef90 100644 --- a/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs +++ b/crates/flowstate/src/ribbon/editor_ribbon/modern_types.rs @@ -101,7 +101,7 @@ impl RibbonLabel { RibbonCommandId::ToggleSpeechDocument => Some("icons/speech.svg"), RibbonCommandId::SendToSpeechDocument | RibbonCommandId::SendToSpeechDocumentEnd => Some("icons/send.svg"), RibbonCommandId::MarkCard => Some("icons/mark-card.svg"), - RibbonCommandId::ClearFormatting => Some("icons/eraser.svg"), + RibbonCommandId::ClearFormatting => Some("icons/remove-formatting.svg"), RibbonCommandId::ExportFormat => Some("icons/export.svg"), RibbonCommandId::ExportSend => Some("icons/send-horizontal.svg"), RibbonCommandId::Revisions => Some("icons/archive.svg"), diff --git a/crates/flowstate/src/rich_text_element/mod.rs b/crates/flowstate/src/rich_text_element/mod.rs index 4bf4a4b9..1239f6a9 100644 --- a/crates/flowstate/src/rich_text_element/mod.rs +++ b/crates/flowstate/src/rich_text_element/mod.rs @@ -94,6 +94,9 @@ pub fn flowstate_command_to_rich_text(command: CommandId) -> Option return None, + | CommandId::ScrollToParagraph + | CommandId::FlowAddSiblingAbove + | CommandId::FlowDeleteSelected + | CommandId::FlowStrike => return None, }) } diff --git a/crates/flowstate/src/workspace/document_panel.rs b/crates/flowstate/src/workspace/document_panel.rs index 8b849ab4..a2a6a870 100644 --- a/crates/flowstate/src/workspace/document_panel.rs +++ b/crates/flowstate/src/workspace/document_panel.rs @@ -58,6 +58,7 @@ impl DocumentPanel { }, DocumentSearchBarEvent::PreviousRequested => panel.select_previous_search_match(cx), DocumentSearchBarEvent::NextRequested => panel.select_next_search_match(cx), + DocumentSearchBarEvent::ApplyReplaceCurrentRequested => panel.apply_replace_current(cx), DocumentSearchBarEvent::ApplyReplaceRequested => panel.apply_replace(cx), DocumentSearchBarEvent::CloseRequested => panel.close_search_bar(cx), }); @@ -164,13 +165,17 @@ impl DocumentPanel { .find_text_with_options(&query, case_sensitive, whole_words); self.search_matches.retain(|range| { let search_bar = self.search_bar.read(cx); - self - .editor - .read(cx) - .document() - .paragraphs - .get(range.start.paragraph) - .is_some_and(|paragraph| search_bar.paragraph_style_enabled(paragraph.style)) + let paragraphs = &self.editor.read(cx).document().paragraphs; + let Some(paragraph) = paragraphs.get(range.start.paragraph) else { + return false; + }; + if !search_bar.paragraph_style_enabled(paragraph.style) { + return false; + } + if search_bar.has_active_run_filters() { + return search_bar.run_style_matches_for_range(paragraph, range); + } + true }); self.active_search_match = (!self.search_matches.is_empty()).then_some(0); } @@ -181,6 +186,20 @@ impl DocumentPanel { cx.notify(); } + fn apply_replace_current(&mut self, cx: &mut Context) { + self.refresh_search_matches(cx); + if self.search_matches.is_empty() || self.active_search_match.is_none() { + return; + } + let replacement = self.search_bar.read(cx).replacement(cx); + let replaced = self + .editor + .update(cx, |editor, cx| editor.replace_active_search_highlight(&replacement, cx)); + if replaced { + self.refresh_search_matches(cx); + } + } + fn apply_replace(&mut self, cx: &mut Context) { // Recompute immediately before replacing. Undo/redo can change the // document without going through the search bar, so cached match offsets diff --git a/crates/flowstate/src/workspace/document_search_overlay.rs b/crates/flowstate/src/workspace/document_search_overlay.rs index a0f5b184..cc04a3b6 100644 --- a/crates/flowstate/src/workspace/document_search_overlay.rs +++ b/crates/flowstate/src/workspace/document_search_overlay.rs @@ -3,15 +3,17 @@ use gpui::{ prelude::*, px, }; use gpui_component::{ - ActiveTheme as _, Icon, IconName, Sizable as _, + ActiveTheme as _, Icon, IconName, Side, Sizable as _, button::{Button, ButtonVariants as _}, checkbox::Checkbox, h_flex, input::{Input, InputEvent, InputState}, - menu::{DropdownMenu as _, PopupMenuItem}, + menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}, }; -use crate::rich_text_element::ParagraphStyle; +use std::ops::Range; + +use crate::rich_text_element::{DocumentOffset, HighlightStyle, Paragraph, ParagraphStyle, RunSemanticStyle}; #[derive(Clone, Copy)] struct SearchStyleFilter { @@ -50,6 +52,64 @@ const SEARCH_STYLE_FILTERS: &[SearchStyleFilter] = &[ }, ]; +#[derive(Clone, Copy)] +struct HighlightFilter { + label: &'static str, + style: HighlightStyle, +} + +const HIGHLIGHT_FILTERS: &[HighlightFilter] = &[ + HighlightFilter { + label: "Spoken", + style: flowstate_document::HIGHLIGHT_SPOKEN, + }, + HighlightFilter { + label: "Insert", + style: flowstate_document::HIGHLIGHT_INSERT, + }, + HighlightFilter { + label: "Alternative", + style: flowstate_document::HIGHLIGHT_ALTERNATIVE, + }, + HighlightFilter { + label: "Marked", + style: flowstate_document::HIGHLIGHT_MARKED, + }, +]; + +#[derive(Clone, Copy)] +struct SemanticStyleFilter { + label: &'static str, + style: RunSemanticStyle, +} + +const SEMANTIC_STYLE_FILTERS: &[SemanticStyleFilter] = &[ + SemanticStyleFilter { + label: "Plain", + style: RunSemanticStyle::Plain, + }, + SemanticStyleFilter { + label: "Cite", + style: flowstate_document::SEMANTIC_CITE, + }, + SemanticStyleFilter { + label: "Emphasis", + style: flowstate_document::SEMANTIC_EMPHASIS, + }, + SemanticStyleFilter { + label: "Underline", + style: flowstate_document::SEMANTIC_UNDERLINE, + }, + SemanticStyleFilter { + label: "Condensed", + style: flowstate_document::SEMANTIC_CONDENSED, + }, + SemanticStyleFilter { + label: "Ultracondensed", + style: flowstate_document::SEMANTIC_ULTRACONDENSED, + }, +]; + #[derive(Clone, Copy, Debug)] pub enum DocumentSearchBarEvent { QueryChanged, @@ -58,6 +118,7 @@ pub enum DocumentSearchBarEvent { StyleFilterChanged, PreviousRequested, NextRequested, + ApplyReplaceCurrentRequested, ApplyReplaceRequested, CloseRequested, } @@ -70,6 +131,10 @@ pub struct DocumentSearchBar { case_sensitive: bool, whole_words: bool, enabled_styles: [bool; SEARCH_STYLE_FILTERS.len()], + highlight_types: [bool; HIGHLIGHT_FILTERS.len()], + underline: bool, + strikethrough: bool, + semantic_styles: [bool; SEMANTIC_STYLE_FILTERS.len()], _input_subscription: Subscription, } @@ -94,6 +159,10 @@ impl DocumentSearchBar { case_sensitive: false, whole_words: false, enabled_styles: [true; SEARCH_STYLE_FILTERS.len()], + highlight_types: [true; HIGHLIGHT_FILTERS.len()], + underline: true, + strikethrough: true, + semantic_styles: [true; SEMANTIC_STYLE_FILTERS.len()], _input_subscription, } } @@ -138,6 +207,82 @@ impl DocumentSearchBar { .is_none_or(|ix| self.enabled_styles[ix]) } + pub fn has_active_run_filters(&self) -> bool { + self.highlight_types.iter().any(|&b| b) || self.underline || self.strikethrough || self.semantic_styles.iter().any(|&b| b) + } + + pub fn run_style_matches_for_range(&self, paragraph: &Paragraph, range: &Range) -> bool { + let mut run_start = 0usize; + for run in ¶graph.runs { + let run_end = run_start + run.len; + if run_start < range.end.byte && run_end > range.start.byte { + if self.highlight_types.iter().any(|&b| b) + && let Some(highlight) = run.styles.highlight + && self.highlight_type_enabled(highlight) + { + return true; + } + if self.underline && run.styles.direct_underline { + return true; + } + if self.strikethrough && run.styles.strikethrough { + return true; + } + if self.semantic_styles.iter().any(|&b| b) && self.semantic_style_enabled(run.styles.semantic) { + return true; + } + } + run_start = run_end; + } + false + } + + fn semantic_style_enabled(&self, style: RunSemanticStyle) -> bool { + SEMANTIC_STYLE_FILTERS + .iter() + .position(|filter| filter.style == style) + .is_some_and(|ix| self.semantic_styles[ix]) + } + + fn highlight_type_enabled(&self, style: HighlightStyle) -> bool { + HIGHLIGHT_FILTERS + .iter() + .position(|filter| filter.style == style) + .is_some_and(|ix| self.highlight_types[ix]) + } + + fn toggle_highlight_type(&mut self, ix: usize, cx: &mut Context) { + self.highlight_types[ix] = !self.highlight_types[ix]; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + + fn toggle_underline(&mut self, cx: &mut Context) { + self.underline = !self.underline; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + + fn toggle_strikethrough(&mut self, cx: &mut Context) { + self.strikethrough = !self.strikethrough; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + + fn toggle_semantic_style(&mut self, ix: usize, cx: &mut Context) { + self.semantic_styles[ix] = !self.semantic_styles[ix]; + self.active_match = None; + self.match_count = 0; + cx.emit(DocumentSearchBarEvent::StyleFilterChanged); + cx.notify(); + } + fn set_case_sensitive(&mut self, case_sensitive: bool, cx: &mut Context) { if self.case_sensitive == case_sensitive { return; @@ -252,7 +397,7 @@ impl Render for DocumentSearchBar { .gap_2() .items_center() .child( - Icon::new(IconName::Search) + Icon::new(IconName::TextSearch) .with_size(px(12.0)) .text_color(cx.theme().muted_foreground), ) @@ -376,9 +521,15 @@ impl Render for DocumentSearchBar { .tooltip("Paragraph styles to search") .dropdown_menu({ let search_bar = search_bar.clone(); - let enabled_styles = self.enabled_styles; - move |menu, _, _| { - SEARCH_STYLE_FILTERS + move |menu, window, cx| { + let sb = search_bar.upgrade().unwrap(); + let enabled_styles = sb.read(cx).enabled_styles; + let highlight_types = sb.read(cx).highlight_types; + let underline = sb.read(cx).underline; + let strikethrough = sb.read(cx).strikethrough; + let semantic_styles = sb.read(cx).semantic_styles; + + let mut menu = SEARCH_STYLE_FILTERS .iter() .enumerate() .fold(menu.min_w(px(140.0)), |menu, (ix, filter)| { @@ -386,11 +537,70 @@ impl Render for DocumentSearchBar { menu.item( PopupMenuItem::new(filter.label) .checked(enabled_styles[ix]) + .keep_open(true) .on_click(move |_, _, cx| { let _ = search_bar.update(cx, |bar, cx| bar.toggle_style_filter(filter.style, cx)); }), ) }) + .separator(); + for (ix, filter) in SEMANTIC_STYLE_FILTERS.iter().enumerate() { + let search_bar = search_bar.clone(); + menu = menu.item( + PopupMenuItem::new(filter.label) + .checked(semantic_styles[ix]) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_semantic_style(ix, cx)); + }), + ); + } + menu = menu.separator(); + + let any_highlight_checked = highlight_types.iter().any(|&b| b); + let highlight_submenu = PopupMenu::build(window, cx, |submenu, _window, _cx| { + let mut submenu = submenu.min_w(px(130.0)).check_side(Side::Right); + for (ix, filter) in HIGHLIGHT_FILTERS.iter().enumerate() { + let search_bar = search_bar.clone(); + submenu = submenu.item( + PopupMenuItem::new(filter.label) + .checked(highlight_types[ix]) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_highlight_type(ix, cx)); + }), + ); + } + submenu + }); + let parent = cx.entity().downgrade(); + highlight_submenu.update(cx, |sub, _| { + sub.parent_menu = Some(parent); + }); + + menu = menu + .item(PopupMenuItem::submenu("Highlight", highlight_submenu).checked(any_highlight_checked)) + .separator() + .item({ + let search_bar = search_bar.clone(); + PopupMenuItem::new("Underline") + .checked(underline) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_underline(cx)); + }) + }) + .item({ + let search_bar = search_bar.clone(); + PopupMenuItem::new("Strikethrough") + .checked(strikethrough) + .keep_open(true) + .on_click(move |_, _, cx| { + let _ = search_bar.update(cx, |bar, cx| bar.toggle_strikethrough(cx)); + }) + }); + + menu } }), ), @@ -404,16 +614,33 @@ impl Render for DocumentSearchBar { ), ) .child( - Button::new("apply-document-search-replace") - .icon( - Icon::default() - .path("icons/replace.svg") - .text_color(cx.theme().muted_foreground), + h_flex() + .gap_0() + .items_center() + .child( + Button::new("apply-document-search-replace-current") + .icon( + Icon::default() + .path("icons/replace.svg") + .text_color(cx.theme().muted_foreground), + ) + .xsmall() + .ghost() + .tooltip("Replace current match") + .on_click(cx.listener(|_, _, _, cx| cx.emit(DocumentSearchBarEvent::ApplyReplaceCurrentRequested))), ) - .xsmall() - .ghost() - .tooltip("Replace all matches") - .on_click(cx.listener(|_, _, _, cx| cx.emit(DocumentSearchBarEvent::ApplyReplaceRequested))), + .child( + Button::new("apply-document-search-replace-all") + .icon( + Icon::default() + .path("icons/replace-all.svg") + .text_color(cx.theme().muted_foreground), + ) + .xsmall() + .ghost() + .tooltip("Replace all matches") + .on_click(cx.listener(|_, _, _, cx| cx.emit(DocumentSearchBarEvent::ApplyReplaceRequested))), + ), ) .child(div().flex_1()) .child( diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index af64a4cd..4a90f622 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -27,8 +27,8 @@ use crate::{ }; use super::{ - APP_CHROME_BORDER_WIDTH, LeftNavMode, OutlineRowGuides, SIDE_PANEL_COLLAPSED_WIDTH, SidebarTreeAction, SidebarTreeRow, ToolkitSearchFilter, - ToolkitTool, Workspace, outline_hierarchy_color, render_sidebar_tree_row, + APP_CHROME_BORDER_WIDTH, LeftNavMode, OutlineRowGuides, SidebarTreeAction, SidebarTreeRow, ToolkitSearchFilter, ToolkitTool, Workspace, + outline_hierarchy_color, render_sidebar_tree_row, }; const TOOLKIT_RESULT_LIMIT: usize = 32; @@ -50,18 +50,8 @@ impl Workspace { /// search results are miniature scrollable windows that can be opened, /// inserted, or dragged into the editor. pub(super) fn render_content_area(&mut self, cx: &mut Context) -> impl IntoElement { - let toolkit_width = if self.toolkit_collapsed { - SIDE_PANEL_COLLAPSED_WIDTH - } else if self.active_toolkit_tool.is_some() { - px(380.0) - } else { - px(40.0) - }; - let toolkit_range_end = if self.toolkit_collapsed || self.active_toolkit_tool.is_none() { - toolkit_width - } else { - px(620.0) - }; + let toolkit_width = if self.active_toolkit_tool.is_some() { px(380.0) } else { px(40.0) }; + let toolkit_range_end = if self.active_toolkit_tool.is_none() { toolkit_width } else { px(620.0) }; h_resizable("workspace-content-resizable") .with_state(&self.content_resizable_state) @@ -82,13 +72,7 @@ impl Workspace { .size(toolkit_width) .size_range(toolkit_width..toolkit_range_end) .grow(false) - .child(if self.toolkit_collapsed { - self - .render_collapsed_side_panel("Show toolkit", IconName::PanelRightOpen, |workspace, cx| workspace.toggle_toolkit(cx), cx) - .into_any_element() - } else { - self.render_toolkit_rail_area(cx).into_any_element() - }), + .child(self.render_toolkit_rail_area(cx)), ) } @@ -143,16 +127,6 @@ impl Workspace { })), ) .child(div().flex_1()) - .child( - Button::new("collapse-toolkit-rail") - .icon(Icon::new(IconName::PanelRightClose).text_color(cx.theme().muted_foreground)) - .xsmall() - .ghost() - .tooltip("Hide toolkit") - .on_click(cx.listener(|workspace, _, _, cx| { - workspace.toggle_toolkit(cx); - })), - ) } pub(super) fn load_tub_root(&mut self, root: PathBuf, cx: &mut Context) { @@ -483,17 +457,13 @@ impl Workspace { cx.notify(); } - fn insert_toolkit_hit(&mut self, hit_ix: usize, window: &mut Window, cx: &mut Context) { + fn insert_toolkit_hit(&mut self, hit_ix: usize, _window: &mut Window, cx: &mut Context) { let Some(hit) = self.toolkit_hits.get(hit_ix).cloned() else { return; }; if let Some(editor) = self.active_editor.clone() { let paragraphs = toolkit_hit_insert_paragraphs(&hit); editor.update(cx, |editor, cx| editor.insert_toolkit_text_at_caret(paragraphs, cx)); - return; - } - if let Some(editor) = self.active_flow.clone() { - editor.update(cx, |editor, cx| editor.insert_toolkit_text(&hit.title, &hit.insert_text, window, cx)); } } @@ -690,7 +660,7 @@ impl Workspace { .justify_center() .child( Button::new(id) - .icon(Icon::new(IconName::FolderOpen).text_color(cx.theme().primary_foreground)) + .icon(Icon::new(IconName::FolderOpen).text_color(cx.theme().secondary_foreground)) .label(label) .small() .tooltip("Select debate tub folder") @@ -902,6 +872,11 @@ impl Workspace { is_active, has_search_match: false, guide, + hierarchy_color: None, + label_color: None, + interaction_palette: None, + guide_colors: None, + incoming_branch: None, icon: Some(icon), icon_color: Some(icon_color), toggle_action: Some(toggle_action), diff --git a/crates/flowstate/src/workspace/workspace/collab.rs b/crates/flowstate/src/workspace/workspace/collab.rs index 8c1d7de8..91486745 100644 --- a/crates/flowstate/src/workspace/workspace/collab.rs +++ b/crates/flowstate/src/workspace/workspace/collab.rs @@ -1,13 +1,23 @@ #[hotpath::measure_all] impl Workspace { pub(crate) fn collaboration_discovery_context(&self, panel_id: Uuid, cx: &App) -> Option<(u128, PathBuf)> { - let panel = self + if let Some(panel) = self .document_panels .iter() + .find(|panel| panel.read(cx).id() == panel_id) + { + let editor = panel.read(cx).editor(); + let editor = editor.read(cx); + return Some((editor.document().ids.document_id, editor.document_path()?.clone())); + } + // FLOW arm: discovery fingerprints derive from `flow.meta/document_id`. + let panel = self + .flow_panels + .iter() .find(|panel| panel.read(cx).id() == panel_id)?; let editor = panel.read(cx).editor(); let editor = editor.read(cx); - Some((editor.document().ids.document_id, editor.document_path()?.clone())) + Some((editor.handle().document_id().ok()?.as_u128(), editor.document_path()?.clone())) } pub fn open_comment_dialog(&mut self, window: &mut Window, cx: &mut Context) { @@ -68,7 +78,9 @@ impl Workspace { self.collaboration_dialog = None; } let workspace = cx.entity().downgrade(); - let panel_id = self.active_editor.as_ref().and(self.active_document_id); + let panel_id = (self.active_editor.is_some() || self.active_flow.is_some()) + .then_some(self.active_document_id) + .flatten(); let dialog = cx.new(|cx| crate::collab::share_dialog::CollabShareDialog::new(workspace, panel_id, mode, window, cx)); let dialog_for_render = dialog.clone(); let workspace_for_close = cx.entity().downgrade(); @@ -93,6 +105,9 @@ impl Workspace { } pub fn start_collaboration_on_document(&mut self, panel_id: Uuid, cx: &mut Context) -> Option { + if self.flow_panels.iter().any(|panel| panel.read(cx).id() == panel_id) { + return self.start_collaboration_on_flow_panel(panel_id, cx); + } let panel = self .document_panels .iter() @@ -121,6 +136,30 @@ impl Workspace { } } + /// FLOW arm of session start: what makes "start collaboration on an open + /// flow tab" free — the editor already commits through its gated authority; + /// the session only takes the flow I/O handle for transport. + fn start_collaboration_on_flow_panel(&mut self, panel_id: Uuid, cx: &mut Context) -> Option { + let panel = self + .flow_panels + .iter() + .find(|panel| panel.read(cx).id() == panel_id)?; + let editor = panel.read(cx).editor(); + let title = panel.read(cx).title_text().to_string(); + let runtime = self.flow_document_runtimes.get(&panel_id)?.clone(); + tracing::info!(%panel_id, title = %title, "workspace starting collaboration on flow"); + match crate::collab::start_flow_session_for_panel(panel_id, editor, title, runtime, cx) { + Ok(session) => { + tracing::info!(%panel_id, %session, "workspace started collaboration on flow"); + Some(session) + }, + Err(error) => { + tracing::error!(%panel_id, error = %format_args!("{error:#}"), "starting flow collaboration session failed"); + None + }, + } + } + pub fn request_active_collaboration_ticket( &mut self, cx: &mut Context, @@ -310,24 +349,43 @@ impl Workspace { )); return; }; - let attachment = DocumentRuntimeAttachment { authority, io }; - let panel = match workspace.add_joined_collaboration_panel(joined.document, joined.title, attachment, window, cx) { - Ok(panel) => panel, - Err(error) => { - tracing::error!(session = %joined.session, error = %format_args!("{error:#}"), "starting joined document runtime failed"); - let detail = format!("Joined document runtime could not be started: {error:#}"); + use crate::collab::{CollabEditor, JoinedAuthority, JoinedDocumentPayload}; + let (panel_id, editor) = match (joined.payload, authority, io) { + (JoinedDocumentPayload::RichText(document), JoinedAuthority::RichText(authority), flowstate_collab::SyncIoHandle::RichText(io)) => { + let attachment = DocumentRuntimeAttachment { authority, io }; + let panel = match workspace.add_joined_collaboration_panel(*document, joined.title, attachment, window, cx) { + Ok(panel) => panel, + Err(error) => { + tracing::error!(session = %joined.session, error = %format_args!("{error:#}"), "starting joined document runtime failed"); + let detail = format!("Joined document runtime could not be started: {error:#}"); + std::mem::drop(window.prompt( + PromptLevel::Critical, + "Join failed", + Some(&detail), + &[PromptButton::ok("Ok")], + cx, + )); + return; + }, + }; + (panel.read(cx).id(), CollabEditor::RichText(panel.read(cx).editor())) + }, + (JoinedDocumentPayload::Flow(_board), JoinedAuthority::Flow(authority), flowstate_collab::SyncIoHandle::Flow(io)) => { + let panel = workspace.add_joined_collaboration_flow_panel(authority, io, joined.title, window, cx); + (panel.read(cx).id(), CollabEditor::Flow(panel.read(cx).editor())) + }, + _ => { + tracing::error!(session = %joined.session, "joined collaboration services have mismatched document kinds"); std::mem::drop(window.prompt( PromptLevel::Critical, "Join failed", - Some(&detail), + Some("The joined collaboration document kind is inconsistent."), &[PromptButton::ok("Ok")], cx, )); return; }, }; - let panel_id = panel.read(cx).id(); - let editor = panel.read(cx).editor(); if let Err(error) = crate::collab::attach_joined_session(joined.session, panel_id, editor, cx) { tracing::error!(session = %joined.session, %panel_id, error = %format_args!("{error:#}"), "collaboration joined document attachment failed"); let detail = format!("Joined document opened, but collaboration attachment failed: {error:#}"); @@ -390,6 +448,24 @@ impl Workspace { Ok(panel) } + /// FLOW join sibling of [`Self::add_joined_collaboration_panel`]: the + /// session built authority + I/O from the snapshot; the panel wires them + /// exactly like a solo flow tab (invariant 5) — pathless, so autosave skips + /// it and the session's recovery hooks cover crashes. + fn add_joined_collaboration_flow_panel( + &mut self, + authority: std::sync::Arc, + io: flowstate_collab::flow::FlowIoHandle, + title: String, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let panel = self.create_flow_panel_from_attachment(authority, io, Some(title), window, cx); + self.persist_temporary_workspace_session(cx); + cx.notify(); + panel + } + pub fn leave_collaboration_on_active_document(&mut self, cx: &mut Context) -> bool { self .active_document_id diff --git a/crates/flowstate/src/workspace/workspace/collab_prompts.rs b/crates/flowstate/src/workspace/workspace/collab_prompts.rs index b1dd6b2f..32086b27 100644 --- a/crates/flowstate/src/workspace/workspace/collab_prompts.rs +++ b/crates/flowstate/src/workspace/workspace/collab_prompts.rs @@ -140,24 +140,30 @@ impl Workspace { } fn collaboration_close_panels(&self, cx: &App) -> Vec { - self - .document_panels - .iter() - .filter_map(|panel| { - let panel_state = panel.read(cx); - let id = panel_state.id(); - let phase = crate::collab::phase_for_panel(id, cx); - collaboration_phase_blocks_close(phase.as_ref()).then(|| { - let panel_kind = PanelKind::Document { - panel: panel.clone(), - editor: panel_state.editor(), - }; - CollaborationClosePanel { - id, - save_prompt: collaboration_save_prompt_for_panel(&panel_kind, cx), - panel: panel_kind, - } - }) + let documents = self.document_panels.iter().filter_map(|panel| { + let panel_state = panel.read(cx); + let id = panel_state.id(); + let phase = crate::collab::phase_for_panel(id, cx); + collaboration_phase_blocks_close(phase.as_ref()).then(|| PanelKind::Document { + panel: panel.clone(), + editor: panel_state.editor(), + }) + }); + let flows = self.flow_panels.iter().filter_map(|panel| { + let panel_state = panel.read(cx); + let id = panel_state.id(); + let phase = crate::collab::phase_for_panel(id, cx); + collaboration_phase_blocks_close(phase.as_ref()).then(|| PanelKind::Flow { + panel: panel.clone(), + editor: panel_state.editor(), + }) + }); + documents + .chain(flows) + .map(|panel_kind| CollaborationClosePanel { + id: panel_kind.panel_id(cx), + save_prompt: collaboration_save_prompt_for_panel(&panel_kind, cx), + panel: panel_kind, }) .collect() } diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index c6b9b404..994d8e0c 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -127,6 +127,8 @@ impl Workspace { editor.update(cx, |editor, cx| { editor.set_zoom_percent(*percent, cx); }); + } else if let Some(flow) = workspace.active_flow.clone() { + flow.update(cx, |flow, cx| flow.set_zoom_percent(*percent, cx)); } }); let workspace = cx.entity().downgrade(); @@ -161,6 +163,7 @@ impl Workspace { let mut this = Self { document_panels: Vec::new(), document_runtimes: FxHashMap::default(), // §perf: FxHash for trusted Uuid keys + flow_document_runtimes: FxHashMap::default(), pending_authority_panels: FxHashSet::default(), document_runtime_flush_pending: FxHashSet::default(), // §perf: FxHash for trusted Uuid keys flow_panels: Vec::new(), @@ -169,7 +172,6 @@ impl Workspace { active_flow: None, ribbon_collapsed: false, outline_collapsed: false, - toolkit_collapsed: false, active_toolkit_tool: None, recent_documents: load_recent_documents(), recent_document_previews: HashMap::new(), @@ -585,7 +587,10 @@ impl Workspace { } pub fn new_flow(&mut self, window: &mut Window, cx: &mut Context) { - self.add_flow_panel(flowstate_flow::FlowDocument::new(), None, window, cx); + let Ok((handle, gate)) = flowstate_collab::flow::FlowDocHandle::new_document(&flowstate_flow::FlowFormat::policy_debate()) else { + return; + }; + self.add_flow_panel(FlowRuntimeSource { handle, gate }, None, window, cx); } pub fn open_demo_document(&mut self, window: &mut Window, cx: &mut Context) { @@ -674,11 +679,11 @@ impl Workspace { }); }); }, - Ok(LoadedWorkspaceDocument::Flow { document, path }) => { + Ok(LoadedWorkspaceDocument::Flow { source, path }) => { let _ = window_handle.update(cx, |_, window, cx| { let _ = workspace.update(cx, |workspace, cx| { workspace.record_recent_document(path.clone(), cx); - workspace.add_flow_panel(document, Some(path), window, cx); + workspace.add_flow_panel(source, Some(path), window, cx); }); }); }, @@ -757,7 +762,6 @@ impl Workspace { active_index, ribbon_collapsed: self.ribbon_collapsed, outline_collapsed: self.outline_collapsed, - toolkit_collapsed: self.toolkit_collapsed, pinned_entry_indices, speech_entry_index, }); @@ -797,10 +801,12 @@ impl Workspace { continue; } let loaded = if matches!(entry.kind, TemporaryWorkspaceSessionEntryKind::Flow) && is_flow_path(&entry.path) { - Some(LoadedWorkspaceDocument::Flow { - document: flowstate_flow::load_flow_document_or_new(&entry.path), - path: entry.path, - }) + load_flow_runtime_source(&entry.path) + .ok() + .map(|source| LoadedWorkspaceDocument::Flow { + source, + path: entry.path, + }) } else { load_workspace_document(entry.path).ok() }; @@ -831,8 +837,8 @@ impl Workspace { viewport_scrolls.push((panel_id, entry.viewport_paragraph)); panel_id }, - LoadedWorkspaceDocument::Flow { document, path } => { - let panel = self.create_flow_panel(document, Some(path), window, cx); + LoadedWorkspaceDocument::Flow { source, path } => { + let panel = self.create_flow_panel(source, Some(path), window, cx); panel.read(cx).id() }, }; @@ -852,7 +858,6 @@ impl Workspace { } self.ribbon_collapsed = session.ribbon_collapsed; self.outline_collapsed = session.outline_collapsed; - self.toolkit_collapsed = session.toolkit_collapsed; self.pinned_document_ids = pinned_ids; self.speech_document_id = speech_id; @@ -1011,17 +1016,51 @@ impl Workspace { fn create_flow_panel( &mut self, - document: flowstate_flow::FlowDocument, + source: FlowRuntimeSource, + path: Option, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let FlowRuntimeSource { handle, gate } = source; + // The flow I/O service shares the editor's gate; collaboration and + // off-thread saves route through it (spec Part C). + let io = flowstate_collab::flow::FlowIoHandle::spawn(gate).ok(); + self.create_flow_panel_inner(handle, io, path, None, window, cx) + } + + /// Joined-session variant: the session already built the write authority and + /// spawned its I/O service from the snapshot, so the panel adopts them + /// instead of spawning a duplicate service on the same gate. Pathless — + /// autosave skips it and the session's recovery hooks cover crashes. + fn create_flow_panel_from_attachment( + &mut self, + handle: std::sync::Arc, + io: flowstate_collab::flow::FlowIoHandle, + title: Option, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + self.create_flow_panel_inner(handle, Some(io), None, title, window, cx) + } + + fn create_flow_panel_inner( + &mut self, + handle: std::sync::Arc, + io: Option, path: Option, + title: Option, window: &mut Window, cx: &mut Context, ) -> Entity { - let editor = cx.new(|cx| FlowEditor::new_with_path(document, path.clone(), window, cx)); + let editor = cx.new(|cx| FlowEditor::new_with_path(handle, path.clone(), window, cx)); let workspace = cx.entity().downgrade(); - let title = path - .as_ref() - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().to_string()) + let title = title + .or_else(|| { + path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()) + }) .or_else(|| Some(self.next_untitled_flow_title(cx))); let panel = cx.new(|cx| FlowPanel::new_with_title(title, path, editor.clone(), workspace, window, cx)); let id = panel.read(cx).id(); @@ -1031,6 +1070,9 @@ impl Workspace { workspace.maybe_autosave_flow(id, editor.clone(), cx); }), )); + if let Some(io) = io { + self.flow_document_runtimes.insert(id, io); + } self.active_document_id = Some(id); self.active_editor = None; self.active_flow = Some(editor); @@ -1042,8 +1084,8 @@ impl Workspace { panel } - fn add_flow_panel(&mut self, document: flowstate_flow::FlowDocument, path: Option, window: &mut Window, cx: &mut Context) { - self.create_flow_panel(document, path, window, cx); + fn add_flow_panel(&mut self, source: FlowRuntimeSource, path: Option, window: &mut Window, cx: &mut Context) { + self.create_flow_panel(source, path, window, cx); self.persist_temporary_workspace_session(cx); cx.notify(); } @@ -1354,9 +1396,16 @@ impl Workspace { return; } let save_task = editor.update(cx, |editor, cx| editor.save(cx)); + let dropbox = self.flow_dropbox_sync_context(&editor, cx); let window_handle = window.window_handle(); cx.spawn(async move |_, cx| { - if let Err(error) = save_task.await { + let result = match save_task.await { + // The bound-path mirror runs after (and only after) a good local + // save, exactly like the .db8 checkpoint hook. + Ok(()) => sync_flow_dropbox_binding(dropbox).await, + Err(error) => Err(error), + }; + if let Err(error) = result { let detail = error.to_string(); let _ = window_handle.update(cx, |_, window, cx| { window.prompt(PromptLevel::Critical, "Save failed", Some(&detail), &[PromptButton::ok("Ok")], cx) @@ -1368,6 +1417,26 @@ impl Workspace { } } + /// Everything the post-save Dropbox mirror needs, captured while the + /// workspace lease is held: the flow's bound path, its document id (the + /// cross-document clobber gate), and the panel's I/O service. + fn flow_dropbox_sync_context( + &self, + editor: &Entity, + cx: &App, + ) -> Option<(PathBuf, Uuid, flowstate_collab::flow::FlowIoHandle)> { + let path = editor.read(cx).document_path()?.clone(); + crate::app_settings::load_dropbox_document_binding(&path)?; + let document_id = editor.read(cx).handle().document_id().ok()?; + let panel_id = self + .flow_panels + .iter() + .find(|panel| panel.read(cx).editor() == *editor) + .map(|panel| panel.read(cx).id())?; + let io = self.flow_document_runtimes.get(&panel_id)?.clone(); + Some((path, document_id, io)) + } + pub fn save_active_as(&mut self, window: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.clone() { self.prompt_save_active_as(editor, window, cx); @@ -1480,8 +1549,13 @@ impl Workspace { self.autosave_flow_in_flight.insert(panel_id); let save_task = editor.update(cx, |editor, cx| editor.save(cx)); + let dropbox = self.flow_dropbox_sync_context(&editor, cx); cx.spawn(async move |workspace, cx| { - if let Err(error) = save_task.await { + let result = match save_task.await { + Ok(()) => sync_flow_dropbox_binding(dropbox).await, + Err(error) => Err(error), + }; + if let Err(error) = result { eprintln!("flow autosave failed: {error}"); } let _ = workspace.update(cx, |workspace, _| { @@ -1749,6 +1823,16 @@ fn write_bytes_to_path(path: &Path, bytes: &[u8]) -> std::io::Result<()> { .map_err(Into::into) } +/// Mirror a just-saved, Dropbox-bound flow to its remote path (spec Part C +/// lifecycle parity). `None` context (no path / no binding / no I/O service) +/// is the common case and a silent no-op. +async fn sync_flow_dropbox_binding(context: Option<(PathBuf, Uuid, flowstate_collab::flow::FlowIoHandle)>) -> std::io::Result<()> { + match context { + Some((path, document_id, io)) => crate::collab::dropbox_checkpoint::sync_bound_flow_file(&path, document_id, &io).await, + None => Ok(()), + } +} + /// Stable short tag for a runtime event variant, used only by fidelity firehose /// lines so a document-runtime commit stream reads which transition was applied. enum LoadedWorkspaceDocument { @@ -1759,18 +1843,30 @@ enum LoadedWorkspaceDocument { title: Option, }, Flow { - document: flowstate_flow::FlowDocument, + source: FlowRuntimeSource, path: PathBuf, }, } +/// A flow write authority + its gate, ready to become a panel (the flow +/// mirror of the .db8 runtime attachment). +struct FlowRuntimeSource { + handle: std::sync::Arc, + gate: std::sync::Arc>, +} + +fn load_flow_runtime_source(path: &std::path::Path) -> Result { + let snapshot = flowstate_flow::read_fl0(path).map_err(|error| error.to_string())?; + let runtime = flowstate_collab::flow::FlowRuntime::from_snapshot(&snapshot).map_err(|error| error.to_string())?; + let (handle, gate) = flowstate_collab::flow::FlowDocHandle::new(runtime); + Ok(FlowRuntimeSource { handle, gate }) +} + #[hotpath::measure] fn load_workspace_document(path: PathBuf) -> Result { if is_flow_path(&path) { - return Ok(LoadedWorkspaceDocument::Flow { - document: flowstate_flow::load_flow_document_or_new(&path), - path, - }); + let source = load_flow_runtime_source(&path)?; + return Ok(LoadedWorkspaceDocument::Flow { source, path }); } load_document_for_open(&path) .map(|loaded| LoadedWorkspaceDocument::Document { @@ -1791,8 +1887,6 @@ struct TemporaryWorkspaceSession { #[serde(default)] outline_collapsed: bool, #[serde(default)] - toolkit_collapsed: bool, - #[serde(default)] pinned_entry_indices: Vec, #[serde(default)] speech_entry_index: Option, diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs index 17b48544..92a95062 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/collab_glue.rs @@ -50,3 +50,162 @@ fn start_and_leave_collaboration_session_round_trip(cx: &mut TestAppContext) { assert!(left, "leaving the session we just started must succeed"); cx.run_until_parked(); } + +/// Build-order step 9 gate: hosting a session on an open FLOW tab registers a +/// live phase + a roster entry for the local user, and leaving keeps the tab +/// fully editable (invariant 5 — the authority never detaches from the tab). +#[gpui::test] +fn start_and_leave_flow_collaboration_session_round_trip(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let panel_id = h + .read(cx, |ws| ws.active_document_id) + .expect("flow panel id"); + + let session = h.update(cx, |ws, _, cx| ws.start_collaboration_on_active_document(cx)); + assert!(session.is_some(), "hosting a session on a fresh flow must succeed"); + cx.run_until_parked(); + + h.update(cx, |_, _, cx| { + let phase = crate::collab::phase_for_panel(panel_id, cx); + assert!( + phase.is_some_and(|phase| !matches!(phase, crate::collab::SessionPhase::Detached(_))), + "hosted flow session must register a live phase" + ); + }); + // The roster fills when the network endpoint comes online (a real OS + // thread, outside the test dispatcher) — wait for it like `wait_until`. + let mut rostered = false; + for _ in 0..500 { + cx.run_until_parked(); + rostered = h.update(cx, |_, _, cx| { + crate::collab::roster_for_panel(panel_id, cx) + .iter() + .any(|entry| entry.is_self) + }); + if rostered { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(rostered, "hosted flow session must roster the local user"); + + let left = h.update(cx, |ws, _, cx| ws.leave_collaboration_on_active_document(cx)); + assert!(left, "leaving the flow session we just started must succeed"); + cx.run_until_parked(); + + // The tab stays editable after leave: a structural intent still commits + // through the same gated authority. + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("active flow"); + let sheets_before = h.update(cx, |_, _, cx| flow.read(cx).board().sheets.len()); + h.update(cx, |_, _, cx| flow.update(cx, |editor, cx| editor.create_sheet(cx))); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + assert_eq!( + flow.read(cx).board().sheets.len(), + sheets_before + 1, + "flow tab must stay editable after leaving the session" + ); + }); +} + +/// Build-order step 10 gate: the join handoff — a snapshot-built flow +/// authority + I/O service become a PATHLESS tab (autosave skips it) that +/// paints the host's board and stays editable through its own gate, with a +/// debounced `.fl0` recovery file that discard removes. +#[gpui::test] +fn flow_join_handoff_pathless_tab_recovery_written_and_discarded(cx: &mut TestAppContext) { + use flowstate_collab::flow::{FlowDocHandle, FlowIoHandle, FlowRuntime}; + use flowstate_flow::FlowIntent; + + // "Remote host" side: a flow with one sheet, snapshotted for the joiner. + let format = flowstate_flow::FlowFormat::policy_debate(); + let (host, _host_gate) = FlowDocHandle::new(FlowRuntime::new(&format).expect("host runtime")); + let sheet_id = uuid::Uuid::new_v4(); + host + .apply(FlowIntent::CreateSheet { + sheet_id, + name: "Shared sheet".into(), + sheet_type_id: format.sheet_types[0].id, + }) + .expect("host sheet"); + let snapshot = host.snapshot().expect("host snapshot"); + let expected_board = host.board_projection().expect("host board"); + + // Joiner session side (the `finish_join_flow_snapshot` shape): runtime from + // snapshot → authority + I/O service, handed to the workspace. + let h = support::open_workspace(cx); + let runtime = FlowRuntime::from_snapshot(&snapshot).expect("joined runtime"); + let (authority, gate) = FlowDocHandle::new(runtime); + let io = FlowIoHandle::spawn(gate).expect("joined flow io"); + h.update(cx, |ws, window, cx| { + ws.add_joined_collaboration_flow_panel(authority, io, "Untitled (shared)".to_string(), window, cx); + }); + cx.run_until_parked(); + + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("joined flow tab active"); + h.update(cx, |_, _, cx| { + let editor = flow.read(cx); + assert!(editor.document_path().is_none(), "joined tab must be pathless (autosave skips it)"); + assert_eq!(editor.board(), &expected_board, "joined tab paints the host's board"); + }); + + // Recovery: the session sets a recovery path on pathless joined tabs; edits + // schedule a debounced snapshot write. + let dir = std::env::temp_dir().join(format!("flowstate-headless-join-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let recovery = dir.join("recovery.fl0"); + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.set_recovery_path(Some(recovery.clone()), cx)); + }); + // The joined tab stays editable through its own authority (invariant 5). + h.update(cx, |_, _, cx| flow.update(cx, |editor, cx| editor.create_sheet(cx))); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + assert_eq!(flow.read(cx).board().sheets.len(), 2, "joined tab edits commit through the gate"); + }); + cx.executor() + .advance_clock(std::time::Duration::from_millis(800)); + let recovery_for_wait = recovery.clone(); + h.wait_until(cx, "flow recovery file write", move |_| recovery_for_wait.exists()); + let recovered = flowstate_flow::read_fl0(&recovery).expect("recovery file is a valid .fl0 v2"); + FlowRuntime::from_snapshot(&recovered).expect("recovery snapshot reloads"); + + // Autosave skipped: the pathless tab stays dirty (nothing saved it). + h.update(cx, |_, _, cx| { + assert!(flow.read(cx).has_unsaved_changes(), "pathless joined tab is never autosaved"); + }); + + // Leave/close discards the recovery file. + h.update(cx, |_, _, cx| flow.update(cx, |editor, _| editor.discard_recovery_file())); + assert!(!recovery.exists(), "discard removes the recovery file"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// Build-order step 9 gate: the window-close prompt cascade covers live FLOW +/// sessions (`collaboration_close_panels` scans `flow_panels` too). +#[gpui::test] +fn flow_collaboration_close_prompt_cascade(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let session = h.update(cx, |ws, _, cx| ws.start_collaboration_on_active_document(cx)); + assert!(session.is_some(), "hosting a session on a fresh flow must succeed"); + cx.run_until_parked(); + + let intercepted = h.update(cx, |ws, window, cx| ws.request_close_window_with_collaboration(window, cx)); + assert!(intercepted, "closing with a live flow session must be intercepted by the leave prompt"); + cx.run_until_parked(); + assert!(cx.has_pending_prompt(), "the leave-and-quit prompt must be pending"); + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + let left = h.update(cx, |ws, _, cx| ws.leave_collaboration_on_active_document(cx)); + assert!(left, "cancelled close must leave the session intact and leavable"); + cx.run_until_parked(); +} diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/documents.rs b/crates/flowstate/src/workspace/workspace/headless_tests/documents.rs index 1421f643..cfb00a30 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/documents.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/documents.rs @@ -77,7 +77,6 @@ fn temporary_session_file_lands_in_sandbox(cx: &mut TestAppContext) { active_index: None, ribbon_collapsed: false, outline_collapsed: false, - toolkit_collapsed: false, pinned_entry_indices: vec![], speech_entry_index: None, }); diff --git a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs index 27406a98..f061fe65 100644 --- a/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs +++ b/crates/flowstate/src/workspace/workspace/headless_tests/flows_and_keystrokes.rs @@ -50,3 +50,223 @@ fn find_keystroke_is_absorbed_without_panic(cx: &mut TestAppContext) { cx.simulate_keystrokes(h.window, "ctrl-f"); cx.run_until_parked(); } + +/// Build-order step 11 gate: synthetic remote presence → peer-color outline +/// on the focused cell + a sheet-switcher dot, and the peer's exact caret +/// (encoded Loro cursors) forwarded into the open cell editor. +#[gpui::test] +fn flow_external_presence_outline_and_cell_caret_forwarding(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("active flow"); + + // One sheet + one cell, typed through the authority, editor open. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + editor.create_sheet(cx); + editor.add_first_argument(cx); + }); + }); + cx.run_until_parked(); + let (sheet_id, cell_id) = h.update(cx, |_, _, cx| { + let board = flow.read(cx).board().clone(); + (board.sheets[0].id, board.sheets[0].cells[0].id) + }); + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + let authority = editor.handle().cell_authority(cell_id); + let projection = editor.handle().open_cell(cell_id).expect("open cell"); + use crate::rich_text_element::{InsertTextIntent, LocalIntent, LocalWriteAuthority, TextAnchor}; + let authority: std::sync::Arc = authority; + authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(projection.ids.paragraph_ids[0], 0), + text: "presence target".into(), + style_override: None, + })) + .expect("cell typing commits"); + editor.activate_cell(cell_id, cx); + }); + }); + cx.run_until_parked(); + + // The "remote" caret: byte offset 8 of paragraph 0, encoded to the exact + // Loro cursor bytes a peer's presence frame would carry. + use crate::rich_text_element::{DocumentOffset, EditorSelection}; + let at = DocumentOffset { paragraph: 0, byte: 8 }; + let caret = h.update(cx, |_, _, cx| { + flow + .read(cx) + .handle() + .presence_selection(cell_id, &EditorSelection::range(at, at)) + .expect("encode presence caret") + }); + + let presence = crate::flow::FlowExternalPresence { + name: "Remote Peer".into(), + color_rgb: 0x00ff_0000, + sheet: Some(sheet_id), + cell: Some(cell_id), + editing: true, + caret: Some(caret), + }; + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.set_external_presences(vec![presence], cx)); + }); + cx.run_until_parked(); + + h.update(cx, |_, _, cx| { + let editor = flow.read(cx); + assert_eq!( + editor + .presence_for_cell(cell_id) + .map(|presence| presence.color_rgb), + Some(0x00ff_0000), + "remote focus must outline the focused cell in the peer color", + ); + assert_eq!( + editor.presence_dots_for_sheet(sheet_id), + vec![0x00ff_0000], + "the peer's sheet must show a presence dot", + ); + let cell_editor = editor.cell_editor(cell_id).expect("open cell editor"); + let carets = cell_editor.read(cx).external_carets_for_paragraph(0); + assert_eq!(carets.len(), 1, "the peer caret must be forwarded into the open cell editor"); + assert_eq!(carets[0].color_rgb, 0x00ff_0000); + assert_eq!(carets[0].offset, at, "the forwarded caret resolves to the exact encoded position"); + }); + + // Clearing presence (leave semantics) clears the forwarded carets too. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.set_external_presences(Vec::new(), cx)); + }); + h.update(cx, |_, _, cx| { + let editor = flow.read(cx); + assert!(editor.presence_for_cell(cell_id).is_none()); + let cell_editor = editor.cell_editor(cell_id).expect("open cell editor"); + assert!( + cell_editor + .read(cx) + .external_carets_for_paragraph(0) + .is_empty() + ); + }); +} + +/// Build-order step 8 gate: solo flow edit/save/undo THROUGH the gated +/// authority path — sheet + cell structural intents, cell typing via the +/// per-cell `FlowCellAuthority`, `.fl0` v2 save round trip, drag commit via +/// `MoveCellSubtree`, and whole-doc undo. +#[gpui::test] +fn flow_edit_save_undo_through_the_authority(cx: &mut TestAppContext) { + let h = support::open_workspace(cx); + h.update(cx, |ws, window, cx| ws.new_flow(window, cx)); + cx.run_until_parked(); + let flow = h + .read(cx, |ws| ws.active_flow.clone()) + .expect("active flow"); + + // Structural edits: sheet + two root cells + typing into the second. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + editor.create_sheet(cx); + editor.add_first_argument(cx); + editor.add_orphan_at_column_top(0, cx); + }); + }); + cx.run_until_parked(); + let (board, first, second) = h.update(cx, |_, _, cx| { + let board = flow.read(cx).board().clone(); + let cells: Vec<_> = board.sheets[0].cells.iter().map(|cell| cell.id).collect(); + (board.clone(), cells[1], cells[0]) + }); + assert_eq!(board.sheets.len(), 1); + assert_eq!(board.sheets[0].cells.len(), 2); + + // Cell typing through the cell authority (the write path an attached + // RichTextEditor drives). + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + let authority = editor.handle().cell_authority(second); + let projection = editor.handle().open_cell(second).expect("open cell"); + use crate::rich_text_element::{InsertTextIntent, LocalIntent, LocalWriteAuthority, TextAnchor}; + let authority: std::sync::Arc = authority; + authority + .apply(LocalIntent::InsertText(InsertTextIntent { + at: TextAnchor::new(projection.ids.paragraph_ids[0], 0), + text: "typed through the gate".into(), + style_override: None, + })) + .expect("cell typing commits"); + editor.sync_board_from_handle(cx); + }); + }); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + let board = flow.read(cx).board().clone(); + let summary = &board.cell(second).expect("cell").1.summary; + assert_eq!(summary.summary_text.as_ref(), "typed through the gate"); + }); + + // Drag commit via MoveCellSubtree: `second` after `first`'s subtree. + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| { + let sheet = editor.board().sheets[0].id; + editor.activate_sheet(sheet, cx); + let moved = editor + .handle() + .apply(flowstate_flow::FlowIntent::MoveCellSubtree { + sheet_id: sheet, + cell_id: second, + drop: flowstate_flow::FlowDropIntent::AfterSibling(first), + }); + assert!(moved.is_ok_and(|outcome| outcome.changed), "drag commit through the gate"); + editor.sync_board_from_handle(cx); + }); + }); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + let order: Vec<_> = flow.read(cx).board().sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect(); + assert_eq!(order, vec![first, second]); + }); + + // Undo restores the pre-move order (whole-doc undo stack). + h.update(cx, |_, _, cx| { + flow.update(cx, |editor, cx| editor.undo(cx)); + }); + cx.run_until_parked(); + h.update(cx, |_, _, cx| { + let order: Vec<_> = flow.read(cx).board().sheets[0] + .cells + .iter() + .map(|cell| cell.id) + .collect(); + assert_eq!(order, vec![second, first], "undo reversed the move"); + }); + + // Save writes a v2 .fl0 that round-trips to the identical board. + let dir = std::env::temp_dir().join(format!("flowstate-headless-flow-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("gate.fl0"); + h.update(cx, |_, _, cx| { + flow + .update(cx, |editor, cx| editor.save_as(path.clone(), cx)) + .detach(); + }); + let flow_for_wait = flow.clone(); + h.wait_until(cx, "flow save to complete", move |_| path.exists()); + cx.run_until_parked(); + let snapshot = flowstate_flow::read_fl0(dir.join("gate.fl0")).expect("read .fl0 v2"); + let reloaded = flowstate_collab::flow::FlowRuntime::from_snapshot(&snapshot).expect("reload"); + h.update(cx, |_, _, cx| { + assert_eq!(reloaded.board_ref(), flow_for_wait.read(cx).board(), "saved board round-trips"); + }); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/flowstate/src/workspace/workspace/keybindings.rs b/crates/flowstate/src/workspace/workspace/keybindings.rs index a68bd7d6..d8e39ad6 100644 --- a/crates/flowstate/src/workspace/workspace/keybindings.rs +++ b/crates/flowstate/src/workspace/workspace/keybindings.rs @@ -35,6 +35,55 @@ impl Workspace { if self.non_document_keybinding_surface_is_open() || self.focused_workspace_input_is_focused(window, cx) { return false; } + if let Some(flow) = self.active_flow.clone() { + let handled = match command { + CommandId::Undo => { + flow.update(cx, |editor, cx| editor.undo(cx)); + true + }, + CommandId::Redo => { + flow.update(cx, |editor, cx| editor.redo(cx)); + true + }, + CommandId::InsertNewline => { + flow.update(cx, |editor, cx| { + editor.add_sibling(flowstate_flow::RelativePosition::After, cx); + editor.focus_active_cell(window, cx); + }); + true + }, + CommandId::InsertSoftLineBreak => { + flow.update(cx, |editor, cx| { + editor.add_response(cx); + editor.focus_active_cell(window, cx); + }); + true + }, + CommandId::FlowAddSiblingAbove => { + flow.update(cx, |editor, cx| { + editor.add_sibling(flowstate_flow::RelativePosition::Before, cx); + editor.focus_active_cell(window, cx); + }); + true + }, + CommandId::Backspace if flow.read(cx).active_cell_is_empty() => { + flow.update(cx, |editor, cx| editor.delete_selected(window, cx)); + true + }, + CommandId::DeleteWordForward | CommandId::FlowDeleteSelected => { + flow.update(cx, |editor, cx| editor.delete_selected(window, cx)); + true + }, + CommandId::ToggleStrikethrough | CommandId::FlowStrike => { + flow.update(cx, |editor, cx| editor.strike_selected(cx)); + true + }, + _ => false, + }; + if handled { + return true; + } + } match command { CommandId::Save => { self.save_active(window, cx); @@ -69,6 +118,9 @@ impl Workspace { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_in(cx)); true + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_in(cx)); + true } else { false } @@ -77,6 +129,9 @@ impl Workspace { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_out(cx)); true + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_out(cx)); + true } else { false } @@ -177,6 +232,7 @@ impl Workspace { true }, CommandId::ScrollToParagraph => false, + CommandId::FlowAddSiblingAbove | CommandId::FlowDeleteSelected | CommandId::FlowStrike => false, command => { if let Some(editor) = self.active_editor.clone() { if let Some(command) = crate::rich_text_element::flowstate_command_to_rich_text(command) { diff --git a/crates/flowstate/src/workspace/workspace/mod.rs b/crates/flowstate/src/workspace/workspace/mod.rs index 5c77017e..73f59128 100644 --- a/crates/flowstate/src/workspace/workspace/mod.rs +++ b/crates/flowstate/src/workspace/workspace/mod.rs @@ -41,7 +41,7 @@ use crate::app_settings::{ load_send_to_document_directory, load_smart_word_selection, load_tub_root, save_autosave, save_document_theme, save_recent_documents, save_send_custom_directory, save_send_to_document_directory, save_smart_word_selection, save_theme_name, }; -use crate::commands::CommandId; +use crate::commands::{CommandId, context_for}; use crate::docx_conversion::{convert_docx_to_document, import_docx_to_loro}; use crate::flow::{FlowEditor, FlowPanel}; use crate::rich_text_element::{ @@ -70,6 +70,7 @@ pub struct Workspace { document_panels: Vec>, // §perf: Uuid keys are locally generated and trusted; use FxHash to avoid SipHash overhead. document_runtimes: FxHashMap, + flow_document_runtimes: FxHashMap, document_runtime_flush_pending: FxHashSet, /// §act-three C (background open): panels painted read-only from a phase-V /// cached projection whose authority runtime has not yet attached (phase G). @@ -81,7 +82,6 @@ pub struct Workspace { active_flow: Option>, ribbon_collapsed: bool, outline_collapsed: bool, - toolkit_collapsed: bool, active_toolkit_tool: Option, recent_documents: Vec, recent_document_previews: HashMap, diff --git a/crates/flowstate/src/workspace/workspace/render_body.rs b/crates/flowstate/src/workspace/workspace/render_body.rs index f6bb2b27..15a0d787 100644 --- a/crates/flowstate/src/workspace/workspace/render_body.rs +++ b/crates/flowstate/src/workspace/workspace/render_body.rs @@ -1,6 +1,6 @@ #[hotpath::measure_all] impl Workspace { - fn render_resizable_workspace(&mut self, cx: &mut Context) -> impl IntoElement { + fn render_resizable_workspace(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if self.document_panels.is_empty() && self.flow_panels.is_empty() { return div() .flex_1() @@ -14,7 +14,7 @@ impl Workspace { .flex_1() .overflow_hidden() .child(self.render_collapsed_ribbon_bar(cx)) - .child(self.render_workspace_body(cx)) + .child(self.render_workspace_body(window, cx)) .into_any_element(); } @@ -43,12 +43,12 @@ impl Workspace { resizable_panel() .size(px(640.0)) .size_range(px(320.0)..Pixels::MAX) - .child(self.render_workspace_body(cx)), + .child(self.render_workspace_body(window, cx)), ) .into_any_element() } - fn render_workspace_body(&mut self, cx: &mut Context) -> impl IntoElement { + fn render_workspace_body(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let panel_sizes = self.body_resizable_state.read(cx).sizes().clone(); let outline_width = if self.outline_collapsed { SIDE_PANEL_COLLAPSED_WIDTH @@ -74,7 +74,7 @@ impl Workspace { .render_collapsed_side_panel("Show outline", IconName::PanelLeftOpen, |workspace, cx| workspace.toggle_outline(cx), cx) .into_any_element() } else { - self.render_left_nav(nav_width, cx).into_any_element() + self.render_left_nav(nav_width, window, cx).into_any_element() }), ) .child( diff --git a/crates/flowstate/src/workspace/workspace/render_outline.rs b/crates/flowstate/src/workspace/workspace/render_outline.rs index fba3ed85..b9624a3a 100644 --- a/crates/flowstate/src/workspace/workspace/render_outline.rs +++ b/crates/flowstate/src/workspace/workspace/render_outline.rs @@ -1,11 +1,11 @@ #[hotpath::measure_all] impl Workspace { - fn render_left_nav(&mut self, nav_width: Pixels, cx: &mut Context) -> AnyElement { + fn render_left_nav(&mut self, nav_width: Pixels, window: &mut Window, cx: &mut Context) -> AnyElement { if self.left_nav_mode == LeftNavMode::Tub { return self.render_tub_nav(nav_width, cx); } if self.active_flow.is_some() { - return self.render_flow_nav(cx); + return self.render_flow_nav(nav_width, window, cx); } self.refresh_outline_tree(cx); self.refresh_outline_viewport(cx); @@ -94,6 +94,11 @@ impl Workspace { is_active: is_active_outline, has_search_match, guide, + hierarchy_color: None, + label_color: None, + interaction_palette: None, + guide_colors: None, + incoming_branch: None, icon: None, icon_color: None, toggle_action: Some(toggle_action), @@ -140,15 +145,110 @@ impl Workspace { outline_paragraphs } - fn render_flow_nav(&mut self, cx: &mut Context) -> AnyElement { - let workspace = cx.entity().downgrade(); - let items = self - .active_flow - .as_ref() - .map(|editor| editor.read(cx).outline_items()) - .unwrap_or_default(); - let len = items.len(); - + fn render_flow_nav(&mut self, nav_width: Pixels, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(editor) = self.active_flow.clone() else { + return div().into_any_element(); + }; + let projection = editor.read(cx).board().clone(); + let active_sheet = editor.read(cx).active_sheet(); + let active_cell = editor.read(cx).active_cell(); + let mut rows = Vec::new(); + for sheet in &projection.sheets { + let sheet_id = sheet.id; + let sheet_expanded = editor.read(cx).outline_item_expanded(sheet_id); + let sheet_palette = projection.format.sheet_type(sheet.sheet_type_id).and_then(|definition| definition.columns.first()).map(|column| { + let side = crate::flow::flow_side_palette(column.side, cx); + crate::flow::FlowSidePalette { + base: sheet_type_color(side.base, cx.theme().sidebar), + foreground: cx.theme().sidebar_accent_foreground, + hover: side.hover.opacity(0.16), + active: cx.theme().sidebar_accent.mix(side.active, 0.36), + } + }); + let sheet_color = sheet_palette.map(|palette| palette.base); + let toggle_editor = editor.clone(); + let activate_editor = editor.clone(); + let key = sheet_id.as_u128() as usize; + // Remote peers focused on this sheet show as colored dots on its + // switcher row (spec Part C presence rendering). + let presence_dots = editor.read(cx).presence_dots_for_sheet(sheet_id); + let sheet_row = render_sidebar_tree_row( + SidebarTreeRow { + row_id: ("flow-outline-sheet", key), + toggle_id: ("flow-outline-sheet-toggle", key), + label_id: ("flow-outline-sheet-label", key), + label: sheet.name.clone().into(), + nav_width, + depth: 0, + is_folder: !sheet.cells.is_empty(), + is_expanded: sheet_expanded, + is_active: active_sheet == Some(sheet_id) && active_cell.is_none(), + has_search_match: false, + guide: OutlineRowGuides::default(), + hierarchy_color: sheet_color, + label_color: None, + interaction_palette: sheet_palette, + guide_colors: None, + incoming_branch: None, + icon: None, + icon_color: None, + toggle_action: Some(Rc::new(move |_, cx| { + toggle_editor.update(cx, |editor, cx| editor.toggle_outline_item(sheet_id, cx)); + })), + label_action: Rc::new(move |_, cx| activate_editor.update(cx, |editor, cx| editor.activate_sheet(sheet_id, cx))), + stop_icon_mouse_down: true, + stop_label_mouse_down: true, + context_menu_action: None, + }, + window, + cx, + ); + rows.push(if presence_dots.is_empty() { + sheet_row.into_any_element() + } else { + div() + .relative() + .child(sheet_row) + .child( + div() + .absolute() + .right_2() + .top_2() + .flex() + .gap_1() + .children(presence_dots.into_iter().map(|color| { + div() + .size(px(8.0)) + .rounded_full() + .bg(Hsla::from(gpui::rgb(color))) + })), + ) + .into_any_element() + }); + if !sheet_expanded { + continue; + } + let Some(definition) = projection.format.sheet_type(sheet.sheet_type_id) else { + continue; + }; + let roots: Vec<_> = sheet.cells.iter().filter(|cell| cell.parent_id.is_none()).map(|cell| cell.id).collect(); + for cell_id in roots { + append_flow_outline_cell_rows( + sheet, + definition, + cell_id, + &editor, + active_cell, + nav_width, + window, + &[0], + sheet_color, + true, + cx, + &mut rows, + ); + } + } v_flex() .size_full() .h_full() @@ -156,133 +256,127 @@ impl Workspace { .p_2() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) - .child( - div() - .w_full() - .flex() - .flex_row() - .items_center() - .justify_between() - .child( - div() - .text_sm() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().sidebar_primary) - .child("Flows"), - ) - .child( - Button::new("collapse-flow-outline-panel") - .icon(Icon::new(IconName::PanelLeftClose).text_color(cx.theme().sidebar_foreground)) - .xsmall() - .ghost() - .tooltip("Collapse outline") - .on_click(cx.listener(|workspace, _, _, cx| { - workspace.toggle_outline(cx); - })), - ), - ) - .child( - v_flex() - .flex_1() - .w_full() - .gap_1() - .overflow_y_scrollbar() - .children(items.into_iter().map(|item| { - let flow_id = item.id.clone(); - let label = item.label.clone(); - let selected = item.selected; - let source_index = item.index; - let target_index = item.index; - let colors = crate::flow::editor::affirmative_flow_colors(cx); - let workspace_for_click = workspace.clone(); - div() - .id(("flow-outline-drop-row", source_index)) - .w_full() - .on_drag( - FlowOutlineDrag { - flow_id: flow_id.clone(), - label: label.clone(), - source_index, - }, - |drag, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| drag.clone()) - }, - ) - .drag_over::(|this, _, _, cx| this.border_t_2().border_color(cx.theme().drag_border)) - .on_drop(cx.listener(move |workspace, drag: &FlowOutlineDrag, window, cx| { - let new_index = flow_drop_index(drag.source_index, target_index); - if let Some(editor) = workspace.active_flow.clone() { - editor.update(cx, |editor, cx| editor.move_flow_to_index(drag.flow_id.clone(), new_index, window, cx)); - } - cx.notify(); - })) - .child( - ListItem::new(("flow-outline-item", source_index)) - .selected(selected) - .rounded(cx.theme().radius) - .on_click(move |_, window, cx| { - let _ = workspace_for_click.update(cx, |workspace, cx| { - if let Some(editor) = workspace.active_flow.clone() { - editor.update(cx, |editor, cx| editor.select_flow(flow_id.clone(), window, cx)); - } - }); - }) - .child( - h_flex() - .w_full() - .min_w_0() - .items_center() - .gap_2() - .child( - div() - .w(px(3.0)) - .h(px(20.0)) - .rounded(px(2.0)) - .bg(colors.border), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_xs() - .truncate() - .text_color(if selected { - cx.theme().sidebar_foreground - } else { - cx.theme().muted_foreground - }) - .child(label), - ) - .child( - Icon::new(IconName::Menu) - .xsmall() - .text_color(cx.theme().muted_foreground), - ), - ), - ) - .into_any_element() - })) - .child( - div() - .id("flow-outline-drop-end") - .h(px(22.0)) - .rounded(cx.theme().radius) - .drag_over::(|this, _, _, cx| this.border_1().border_color(cx.theme().drag_border)) - .on_drop(cx.listener(move |workspace, drag: &FlowOutlineDrag, window, cx| { - if len > 0 - && let Some(editor) = workspace.active_flow.clone() - { - editor.update(cx, |editor, cx| editor.move_flow_to_index(drag.flow_id.clone(), usize::MAX, window, cx)); - cx.notify(); - } - })), - ), - ) + .child(self.render_left_nav_header("Flow", cx)) + .child(div().flex_1().w_full().overflow_y_scrollbar().children(rows)) .into_any_element() } } +fn append_flow_outline_cell_rows( + sheet: &flowstate_flow::Sheet, + definition: &flowstate_flow::SheetTypeDefinition, + cell_id: flowstate_flow::CellId, + editor: &Entity, + active_cell: Option, + nav_width: Pixels, + window: &mut Window, + ancestor_depths: &[usize], + sheet_color: Option, + is_final_child: bool, + cx: &mut Context, + rows: &mut Vec, +) { + let Some(cell) = sheet.cells.iter().find(|cell| cell.id == cell_id) else { + return; + }; + let Some(column_depth) = definition.columns.iter().position(|column| column.id == cell.column_id) else { + return; + }; + let depth = column_depth + 1; + let children: Vec<_> = sheet + .cells + .iter() + .filter(|candidate| candidate.parent_id == Some(cell_id)) + .map(|candidate| candidate.id) + .collect(); + let expanded = editor.read(cx).outline_item_expanded(cell_id); + let side_palette = crate::flow::flow_side_palette(definition.columns[column_depth].side, cx); + let side_color = side_palette.base; + let guide_colors = (0..depth) + .map(|guide_depth| { + if guide_depth == 0 { + sheet_color.unwrap_or_else(|| outline_hierarchy_color(0, cx)) + } else { + crate::flow::flow_side_palette(definition.columns[guide_depth - 1].side, cx).base + } + }) + .collect(); + let label = { + let text = cell.summary.summary_text.to_string(); + if text.trim().is_empty() { "(empty)".to_string() } else { text } + }; + let activate_editor = editor.clone(); + let toggle_editor = editor.clone(); + let incoming_branch = cell.parent_id.map(|_| IncomingBranch { + parent_depth: depth.saturating_sub(1), + is_final_child, + }); + let guide = OutlineRowGuides { + ancestor_depths: ancestor_depths.to_vec(), + // Flow branches are rendered in their parent's indentation slot. The + // document-outline toggle extension uses a different coordinate and would + // leave a short duplicate segment below every expanded flow parent. + extends_from_toggle: false, + }; + let key = cell_id.as_u128() as usize; + rows.push( + render_sidebar_tree_row( + SidebarTreeRow { + row_id: ("flow-outline-cell", key), + toggle_id: ("flow-outline-toggle", key), + label_id: ("flow-outline-label", key), + label: label.into(), + nav_width, + depth, + is_folder: !children.is_empty(), + is_expanded: expanded, + is_active: active_cell == Some(cell_id), + has_search_match: false, + guide, + hierarchy_color: Some(side_color), + label_color: None, + interaction_palette: Some(side_palette), + guide_colors: Some(guide_colors), + incoming_branch, + icon: None, + icon_color: None, + toggle_action: Some(Rc::new(move |_, cx| { + toggle_editor.update(cx, |editor, cx| editor.toggle_outline_item(cell_id, cx)); + })), + label_action: Rc::new(move |_, cx| activate_editor.update(cx, |editor, cx| editor.activate_cell(cell_id, cx))), + stop_icon_mouse_down: true, + stop_label_mouse_down: true, + context_menu_action: None, + }, + window, + cx, + ) + .into_any_element(), + ); + if expanded { + let mut child_ancestors = ancestor_depths.to_vec(); + if !is_final_child { + child_ancestors.push(depth.saturating_sub(1)); + } + for (index, child_id) in children.iter().copied().enumerate() { + append_flow_outline_cell_rows( + sheet, + definition, + child_id, + editor, + active_cell, + nav_width, + window, + &child_ancestors, + sheet_color, + index + 1 == children.len(), + cx, + rows, + ); + } + } +} + type SidebarTreeAction = Rc; type ContextMenuAction = Rc, &mut Window, &mut App)>; @@ -298,6 +392,11 @@ struct SidebarTreeRow { is_active: bool, has_search_match: bool, guide: OutlineRowGuides, + hierarchy_color: Option, + label_color: Option, + interaction_palette: Option, + guide_colors: Option>, + incoming_branch: Option, icon: Option, icon_color: Option, toggle_action: Option, @@ -307,9 +406,19 @@ struct SidebarTreeRow { context_menu_action: Option, } +#[derive(Clone, Copy)] +struct IncomingBranch { + parent_depth: usize, + is_final_child: bool, +} + fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut App) -> ListItem { - let hierarchy_color = outline_hierarchy_color(row.depth, cx); + let hierarchy_color = row.hierarchy_color.unwrap_or_else(|| outline_hierarchy_color(row.depth, cx)); + let label_color = row.label_color.unwrap_or(hierarchy_color); + let interaction_palette = row.interaction_palette; + let guide_colors = row.guide_colors; let guide_depths = row.guide.ancestor_depths; + let incoming_branch = row.incoming_branch; let label_width = outline_label_width(row.nav_width, row.depth); let label = truncate_outline_label(row.label.as_ref(), outline_label_text_width(label_width, window), window, cx); let icon_color = row.icon_color.unwrap_or(hierarchy_color); @@ -346,13 +455,18 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .gap_1() .children((0..row.depth).map(|guide_depth| { let has_guide = guide_depths.contains(&guide_depth); - let guide_color = outline_hierarchy_color(guide_depth, cx); + let incoming_branch = incoming_branch.filter(|branch| branch.parent_depth == guide_depth); + let guide_color = guide_colors + .as_ref() + .and_then(|colors| colors.get(guide_depth)) + .copied() + .unwrap_or_else(|| outline_hierarchy_color(guide_depth, cx)); div() .relative() .w(px(12.0)) .h(px(20.0)) .flex_none() - .when(has_guide, |this| { + .when(has_guide && incoming_branch.is_none(), |this| { this.child( div() .absolute() @@ -363,6 +477,42 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .bg(guide_color.opacity(0.68)), ) }) + .when_some(incoming_branch, |this, branch| { + if branch.is_final_child { + this.child( + div() + .absolute() + .top_0() + .left(px(11.0)) + .w(px(9.0)) + .h(px(10.5)) + .border_l_1() + .border_b_1() + .border_color(guide_color.opacity(0.68)) + .rounded_bl(cx.theme().radius.min(px(10.0))), + ) + } else { + this + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(11.5)) + .w(px(0.5)) + .bg(guide_color.opacity(0.68)), + ) + .child( + div() + .absolute() + .top(px(10.0)) + .left(px(11.5)) + .w(px(8.5)) + .h(px(0.5)) + .bg(guide_color.opacity(0.68)), + ) + } + }) .into_any_element() })) .child( @@ -391,7 +541,10 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap this.child( Button::new(row.toggle_id) .xsmall() - .ghost() + .when(incoming_branch.is_none(), |button| button.ghost()) + .when(incoming_branch.is_some(), |button| { + button.custom(ButtonCustomVariant::new(cx).foreground(hierarchy_color)) + }) .absolute() .top_0() .left_0() @@ -448,9 +601,9 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .px_1() .overflow_hidden() .text_color(if row.is_active { - cx.theme().sidebar_accent_foreground + interaction_palette.map_or(cx.theme().sidebar_accent_foreground, |palette| palette.foreground) } else { - hierarchy_color + label_color }) .whitespace_nowrap() .rounded(cx.theme().radius) @@ -474,18 +627,20 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap .left_0() .right_0() .bottom_0() - .bg( - cx.theme() - .sidebar_accent - .opacity(if row.has_search_match { 0.55 } else { 1.0 }), - ) + .bg(interaction_palette.map_or(cx.theme().sidebar_accent, |palette| palette.active).opacity(if row.has_search_match { + 0.55 + } else { + 1.0 + })) .border_1() .border_color(hierarchy_color) .rounded(cx.theme().radius), ) }) .when(!row.is_active && !row.has_search_match, |this| { - this.hover(|style| style.bg(cx.theme().list_hover)) + this.hover(|style| { + style.bg(interaction_palette.map_or(cx.theme().list_hover, |palette| palette.hover.opacity(0.14))) + }) }) .child(label) .on_mouse_down(MouseButton::Left, move |_, _, cx| { @@ -508,33 +663,7 @@ fn render_sidebar_tree_row(row: SidebarTreeRow, window: &mut Window, cx: &mut Ap ) } -#[derive(Clone)] -struct FlowOutlineDrag { - flow_id: String, - label: String, - source_index: usize, -} -#[hotpath::measure_all] -impl Render for FlowOutlineDrag { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .id("flow-outline-drag") - .w(px(180.0)) - .items_center() - .gap_2() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().drag_border) - .bg(cx.theme().popover.opacity(0.92)) - .px_2() - .py_1() - .text_xs() - .text_color(cx.theme().popover_foreground) - .child(Icon::new(IconName::Menu).xsmall()) - .child(div().flex_1().truncate().child(self.label.clone())) - } -} #[hotpath::measure] fn outline_hierarchy_color(depth: usize, cx: &App) -> Hsla { @@ -548,11 +677,37 @@ fn outline_hierarchy_color(depth: usize, cx: &App) -> Hsla { } } -#[hotpath::measure] -fn flow_drop_index(source_index: usize, target_index: usize) -> usize { - if source_index < target_index { - target_index.saturating_sub(1) - } else { - target_index +fn sheet_type_color(side: Hsla, background: Hsla) -> Hsla { + let mut color = Hsla { + s: (side.s * 0.70).clamp(0.46, 0.78), + l: if background.l < 0.5 { + (side.l + 0.18).clamp(0.52, 0.78) + } else { + (side.l - 0.18).clamp(0.22, 0.48) + }, + ..side + }; + if contrast_ratio(color, background) < 3.0 { + color.l = if background.l < 0.5 { 0.72 } else { 0.28 }; } + color } + +fn contrast_ratio(foreground: Hsla, background: Hsla) -> f32 { + let luminance = |color: Hsla| { + let rgb = color.to_rgb(); + let linear = |channel: f32| { + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(rgb.r) + 0.7152 * linear(rgb.g) + 0.0722 * linear(rgb.b) + }; + let foreground = luminance(foreground); + let background = luminance(background); + (foreground.max(background) + 0.05) / (foreground.min(background) + 0.05) +} + + diff --git a/crates/flowstate/src/workspace/workspace/render_status.rs b/crates/flowstate/src/workspace/workspace/render_status.rs index bc19e9ec..c12b352e 100644 --- a/crates/flowstate/src/workspace/workspace/render_status.rs +++ b/crates/flowstate/src/workspace/workspace/render_status.rs @@ -48,7 +48,7 @@ impl Workspace { ) .child( icon_button("tab-bar-save-file", AppIcon::SaveFile) - .tooltip("Save current file") + .tooltip_with_action("Save current file", &Save, context_for(CommandId::Save)) .on_click(cx.listener(|workspace, _, window, cx| { workspace.save_active(window, cx); })), @@ -299,7 +299,11 @@ impl Workspace { } else { None }; - let zoom = self.active_editor.as_ref().map(|editor| editor.read(cx).zoom_percent()); + let zoom = self + .active_editor + .as_ref() + .map(|editor| editor.read(cx).zoom_percent()) + .or_else(|| self.active_flow.as_ref().map(|flow| flow.read(cx).zoom_percent())); let collab_phase = self .active_document_id .and_then(|panel_id| crate::collab::phase_for_panel(panel_id, cx)); diff --git a/crates/flowstate/src/workspace/workspace/render_top_bar.rs b/crates/flowstate/src/workspace/workspace/render_top_bar.rs index a39caaff..b54893bd 100644 --- a/crates/flowstate/src/workspace/workspace/render_top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/render_top_bar.rs @@ -25,10 +25,13 @@ impl Workspace { cx, !self.outline_collapsed, !self.ribbon_collapsed, - !self.toolkit_collapsed, )) .child(div().flex_1()) - .child(share_top_bar_button(cx, self.active_editor.is_some(), active_collaborating)) + .child(share_top_bar_button( + cx, + self.active_editor.is_some() || self.active_flow.is_some(), + active_collaborating, + )) .child(settings_top_bar_button(cx)), ) } diff --git a/crates/flowstate/src/workspace/workspace/theme.rs b/crates/flowstate/src/workspace/workspace/theme.rs index 8a95df72..463a8252 100644 --- a/crates/flowstate/src/workspace/workspace/theme.rs +++ b/crates/flowstate/src/workspace/workspace/theme.rs @@ -10,10 +10,6 @@ fn apply_app_theme_config(theme_name: &str, window: Option<&mut Window>, cx: &mu true } -fn preview_app_theme(theme_name: &str, window: Option<&mut Window>, cx: &mut App) { - let _ = apply_app_theme_config(theme_name, window, cx); -} - fn apply_app_theme(theme_name: &str, window: Option<&mut Window>, cx: &mut App) { if !apply_app_theme_config(theme_name, window, cx) { return; diff --git a/crates/flowstate/src/workspace/workspace/top_bar.rs b/crates/flowstate/src/workspace/workspace/top_bar.rs index f2ba8d8e..c94ed759 100644 --- a/crates/flowstate/src/workspace/workspace/top_bar.rs +++ b/crates/flowstate/src/workspace/workspace/top_bar.rs @@ -37,30 +37,14 @@ fn flowstate_top_bar_button(cx: &mut Context) -> impl IntoElement { .scrollbar_show(gpui_component::scroll::ScrollbarShow::Always); theme_names.iter().fold(menu, |menu, theme_name| { let selected = theme_name == ¤t_theme; - let label = theme_name.clone(); - let preview_theme = theme_name.clone(); - let restore_theme = current_theme.clone(); let apply_theme = theme_name.clone(); - let committed = Rc::new(Cell::new(false)); menu.item( - PopupMenuItem::new(label) + PopupMenuItem::new(theme_name.clone()) .checked(selected) - .on_hover({ - let committed = committed.clone(); - move |hovered, window, cx| { - if *hovered { - preview_app_theme(&preview_theme, Some(window), cx); - } else if !committed.get() { - preview_app_theme(&restore_theme, Some(window), cx); - } - } - }) - .on_click({ - let committed = committed.clone(); - move |_, window, cx| { - committed.set(true); - apply_app_theme(&apply_theme, Some(window), cx); - } + .keep_open(true) + .radio(true) + .on_click(move |_, window, cx| { + apply_app_theme(&apply_theme, Some(window), cx); }), ) }) @@ -390,7 +374,7 @@ fn settings_top_bar_button(cx: &mut Context) -> impl IntoElement { } #[hotpath::measure] -fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_open: bool, toolkit_open: bool) -> impl IntoElement { +fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_open: bool) -> impl IntoElement { let workspace = cx.entity().downgrade(); div() .h_full() @@ -407,11 +391,11 @@ fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_o .dropdown_menu(move |menu, _, _| { let outline_workspace = workspace.clone(); let ribbon_workspace = workspace.clone(); - let toolkit_workspace = workspace.clone(); menu .item( PopupMenuItem::new("Outline") .checked(outline_open) + .keep_open(true) .on_click(move |_, _, cx| { let _ = outline_workspace.update(cx, |workspace, cx| workspace.toggle_outline(cx)); }), @@ -419,17 +403,11 @@ fn view_top_bar_button(cx: &mut Context, outline_open: bool, ribbon_o .item( PopupMenuItem::new("Ribbon") .checked(ribbon_open) + .keep_open(true) .on_click(move |_, _, cx| { let _ = ribbon_workspace.update(cx, |workspace, cx| workspace.toggle_ribbon(cx)); }), ) - .item( - PopupMenuItem::new("Toolkit") - .checked(toolkit_open) - .on_click(move |_, _, cx| { - let _ = toolkit_workspace.update(cx, |workspace, cx| workspace.toggle_toolkit(cx)); - }), - ) }), ) } diff --git a/crates/flowstate/src/workspace/workspace/traits.rs b/crates/flowstate/src/workspace/workspace/traits.rs index bfe9caf6..a770e206 100644 --- a/crates/flowstate/src/workspace/workspace/traits.rs +++ b/crates/flowstate/src/workspace/workspace/traits.rs @@ -19,6 +19,12 @@ impl Render for Workspace { } else { editor.update(cx, |editor, cx| editor.zoom_out(cx)); } + } else if let Some(flow) = workspace.active_flow.clone() { + if delta.y < px(0.0) { + flow.update(cx, |flow, cx| flow.zoom_in(cx)); + } else { + flow.update(cx, |flow, cx| flow.zoom_out(cx)); + } } }); } @@ -47,7 +53,7 @@ impl Render for Workspace { .flex_1() .min_h_0() .overflow_hidden() - .child(self.render_resizable_workspace(cx)), + .child(self.render_resizable_workspace(window, cx)), ) .child(self.render_status_bar(window, cx)), ) diff --git a/crates/flowstate/src/workspace/workspace/workspace_state.rs b/crates/flowstate/src/workspace/workspace/workspace_state.rs index 4d4a8372..d83046a0 100644 --- a/crates/flowstate/src/workspace/workspace/workspace_state.rs +++ b/crates/flowstate/src/workspace/workspace/workspace_state.rs @@ -25,33 +25,7 @@ impl Workspace { cx.notify(); } - pub fn toggle_toolkit(&mut self, cx: &mut Context) { - let width = self - .content_resizable_state - .read(cx) - .sizes() - .get(1) - .copied() - .unwrap_or(px(300.0)); - let delta = if self.toolkit_collapsed { - SIDE_PANEL_COLLAPSED_WIDTH - width - } else { - width - SIDE_PANEL_COLLAPSED_WIDTH - }; - self.prepare_active_editor_for_width_delta(delta, cx); - self.toolkit_collapsed = !self.toolkit_collapsed; - if self.toolkit_collapsed { - self.active_toolkit_tool = None; - } - self.persist_temporary_workspace_session(cx); - cx.notify(); - } - fn toggle_toolkit_tool(&mut self, tool: ToolkitTool, cx: &mut Context) { - if self.toolkit_collapsed { - self.toolkit_collapsed = false; - } - let was_expanded = self.active_toolkit_tool.is_some(); self.active_toolkit_tool = if self.active_toolkit_tool == Some(tool) { None } else { Some(tool) }; diff --git a/crates/flowstate/src/workspace/workspace/zoom_status.rs b/crates/flowstate/src/workspace/workspace/zoom_status.rs index 8612ee01..b5f6ef1e 100644 --- a/crates/flowstate/src/workspace/workspace/zoom_status.rs +++ b/crates/flowstate/src/workspace/workspace/zoom_status.rs @@ -3,12 +3,16 @@ impl Workspace { fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_in(cx)); + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_in(cx)); } } fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context) { if let Some(editor) = self.active_editor.clone() { editor.update(cx, |editor, cx| editor.zoom_out(cx)); + } else if let Some(flow) = self.active_flow.clone() { + flow.update(cx, |flow, cx| flow.zoom_out(cx)); } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs b/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs index fe5bf744..c5d9c82f 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/action_handlers.rs @@ -178,6 +178,9 @@ impl RichTextEditor { self.delete_forward_command(cx); } fn on_insert_newline(&mut self, _: &InsertNewline, _: &mut Window, cx: &mut Context) { + if !self.config.allow_paragraph_breaks { + return; + } if self.split_selected_table_cell_paragraph(cx) { return; } diff --git a/crates/gpui-flowtext/src/rich_text/editor/commands.rs b/crates/gpui-flowtext/src/rich_text/editor/commands.rs index 30cbdacc..ae2c686c 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/commands.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/commands.rs @@ -458,6 +458,19 @@ impl RichTextEditor { let Some(item) = cx.read_from_clipboard() else { return; }; + if !self.config.allow_paragraph_breaks { + let rich_single_paragraph = item + .metadata() + .and_then(|metadata| serde_json::from_str::(metadata).ok()) + .filter(|fragment| fragment.blocks.is_empty() && fragment.paragraphs.len() <= 1); + if let Some(fragment) = rich_single_paragraph { + self.apply_document_edit(cx, |editor, cx| editor.insert_rich_fragment(fragment, cx)); + } else if let Some(text) = item.text() { + let flattened = text.split_whitespace().collect::>().join(" "); + self.insert_text_command(&flattened, cx); + } + return; + } if matches!(self.selected_block, Some(BlockSelection::Equation(_))) { if let Some(text) = item.text() { self.insert_text_into_selected_equation(&text, cx); diff --git a/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs b/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs index 98d7b4bc..bfcc5d05 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/item_sizes.rs @@ -18,6 +18,7 @@ impl RichTextEditor { let width = self .measured_item_width .unwrap_or(if has_measured_viewport { viewport_width } else { px(900.0) }); + self.prepare_pending_zoom_anchor(width, window, cx); if has_measured_viewport && self.initial_layout_hidden { self.ensure_exact_initial_viewport_chunks(width, window, cx); } @@ -69,6 +70,7 @@ impl RichTextEditor { window: &mut Window, cx: &mut Context, ) -> Rc>> { + let zoom_anchor = self.active_zoom_anchor(); let old_cache = self.item_sizes_cache.take(); let (items, block_item_ranges, block_heights, sizes) = self.virtual_item_sizes(width, old_cache, window, cx); let (paragraph_chunk_item_ranges, paragraph_remainder_items) = item_lookup_for_virtual_items(items.as_ref(), self.document.paragraphs.len()); @@ -89,6 +91,7 @@ impl RichTextEditor { sizes: sizes.clone(), }); self.restore_scroll_anchor(scroll_anchor); + self.restore_preserved_zoom_anchor(zoom_anchor); if schedule_prefetch { self.schedule_chunk_prefetch(width, window, cx); } @@ -102,6 +105,7 @@ impl RichTextEditor { window: &mut Window, cx: &mut Context, ) -> Option>>> { + let zoom_anchor = self.active_zoom_anchor(); let range = self.pending_item_sizes_patch_range.clone()?; let paragraph_count = self.document.paragraphs.len(); if range.start > range.end || range.end > paragraph_count { @@ -188,6 +192,7 @@ impl RichTextEditor { } self.pending_item_sizes_patch_range = None; self.restore_scroll_anchor(scroll_anchor); + self.restore_preserved_zoom_anchor(zoom_anchor); self.schedule_chunk_prefetch(width, window, cx); Some(patched_sizes) } diff --git a/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs b/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs index 427d327b..9cb9c1a3 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/layout_access.rs @@ -416,6 +416,7 @@ impl RichTextEditor { } let hide_until_viewport_measured = self.scroll_handle.bounds().size.width <= px(1.0); let mut item_sizes = self.paragraph_item_sizes(window, cx); + self.apply_pending_zoom_anchor(); let has_startup_layout_width = self.measured_item_width.is_some() || self.document.paragraphs.is_empty(); if !hide_until_viewport_measured && self.initial_layout_hidden && has_startup_layout_width { self.initial_layout_hidden = false; @@ -434,6 +435,7 @@ impl RichTextEditor { && let Some(cache) = &self.item_sizes_cache { item_sizes = cache.sizes.clone(); + self.apply_pending_zoom_anchor(); } let base_items = self .item_sizes_cache diff --git a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs index 191cfcff..7ce44b98 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs @@ -77,6 +77,8 @@ impl RichTextEditor { last_send_document_generation: None, last_format_export_generation: None, zoom_percent: 100.0, + zoom_anchor: None, + zoom_anchor_apply_pending: false, save_status: SaveStatus::Saved, identity_map, reconciliation_recoveries: 0, @@ -202,6 +204,8 @@ impl RichTextEditor { self.last_send_document_generation = None; self.last_format_export_generation = None; self.zoom_percent = 100.0; + self.zoom_anchor = None; + self.zoom_anchor_apply_pending = false; self.collapsed_section_ids.clear(); self.hovered_collapse_paragraph = None; self.document.theme.zoom_factor = 1.0; diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index 0312a06a..53884412 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -400,9 +400,13 @@ pub struct RichTextEditorStyleState { /// /// This is intentionally separate from document data. Future settings UI can /// edit this object without changing saved document content. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct RichTextEditorConfig { pub smart_word_selection: bool, + pub allow_paragraph_breaks: bool, + pub flow_cell_surface: bool, + pub show_section_collapse_controls: bool, + pub caret_color: Option, pub show_own_collaboration_caret_color: bool, } @@ -411,6 +415,10 @@ impl Default for RichTextEditorConfig { fn default() -> Self { Self { smart_word_selection: true, + allow_paragraph_breaks: true, + flow_cell_surface: false, + show_section_collapse_controls: true, + caret_color: None, show_own_collaboration_caret_color: true, } } @@ -906,6 +914,8 @@ pub struct RichTextEditor { last_send_document_generation: Option, last_format_export_generation: Option, zoom_percent: f32, + zoom_anchor: Option, + zoom_anchor_apply_pending: bool, save_status: SaveStatus, identity_map: DocumentIdentityMap, reconciliation_recoveries: u64, diff --git a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs index 456bb48d..65beb19d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mouse.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mouse.rs @@ -9,7 +9,7 @@ impl RichTextEditor { self.last_drag_position = Some(event.position); self.goal_x = None; let offset = self.hit_test_document_position(event.position, window, cx); - if self.collapse_gutter_hit(event.position, offset.paragraph) { + if self.config.show_section_collapse_controls && self.collapse_gutter_hit(event.position, offset.paragraph) { self.toggle_section_collapsed_at_paragraph(offset.paragraph, &[0, 1, 2, 3], cx); return; } @@ -97,10 +97,9 @@ impl RichTextEditor { let paragraph_ix = self .hit_test_document_position(event.position, window, cx) .paragraph; - let next_hover = self - .section_collapsed_at_heading(paragraph_ix, &[0, 1, 2, 3]) - .is_some() - .then_some(paragraph_ix); + let next_hover = self.config.show_section_collapse_controls.then_some(paragraph_ix).filter(|paragraph_ix| { + self.section_collapsed_at_heading(*paragraph_ix, &[0, 1, 2, 3]).is_some() + }); if self.hovered_collapse_paragraph != next_hover { self.hovered_collapse_paragraph = next_hover; cx.notify(); diff --git a/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs b/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs index 8d0b6ba3..c537be8a 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/scroll_anchor.rs @@ -47,16 +47,13 @@ impl RichTextEditor { let delta = (content_y - item_top).max(px(0.0)); match item { VirtualItem::ParagraphRemainder { paragraph_ix, .. } => { - let width = self.current_layout_width(); - let start_byte = self - .valid_chunk_cache_entry(paragraph_ix, width) - .and_then(|entry| entry.chunks.last()) - .map(|chunk| chunk.end_byte) - .unwrap_or(0); + let block_ix = self.block_ix_for_paragraph(paragraph_ix)?; + let paragraph_start_item = self.item_sizes_cache.as_ref()?.block_item_ranges.get(block_ix)?.start; + let paragraph_top = self.height_prefix_index.item_top(paragraph_start_item); Some(ScrollAnchorSnapshot::ParagraphRemainder { paragraph_ix, - start_byte, - delta, + start_byte: 0, + delta: (content_y - paragraph_top).max(px(0.0)), }) }, item => Some(ScrollAnchorSnapshot::Item { item, delta }), diff --git a/crates/gpui-flowtext/src/rich_text/editor/traits.rs b/crates/gpui-flowtext/src/rich_text/editor/traits.rs index 80390957..3c33bb09 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/traits.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/traits.rs @@ -37,11 +37,14 @@ impl Render for RichTextEditor { let render_item_sizes = item_sizes.clone(); let render_items = render_layout.items.clone(); let hide_initial_layout = render_layout.hide_initial_layout; + let flow_cell_surface = self.config.flow_cell_surface; + let flow_cell_height = item_sizes.iter().fold(px(1.0), |height, item| height + item.height); div() - .size_full() .id("rich-text-editor") .relative() - .bg(self.document.theme.document_background_color) + .when(flow_cell_surface, |this| this.w_full().h(flow_cell_height)) + .when(!flow_cell_surface, |this| this.size_full()) + .when(!flow_cell_surface, |this| this.bg(self.document.theme.document_background_color)) .track_focus(&self.focus_handle(cx)) .key_context("RichTextEditor") .cursor(CursorStyle::IBeam) @@ -116,6 +119,12 @@ impl Render for RichTextEditor { .on_mouse_move(cx.listener(Self::on_mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_scroll_wheel(cx.listener(|editor, event: &gpui::ScrollWheelEvent, window, cx| { + if !event.modifiers.control { + editor.reset_zoom_anchor(); + cx.on_next_frame(window, |editor, _, _| editor.reset_zoom_anchor()); + } + })) .drag_over::(|style, _, _, cx| style.border_1().border_color(cx.theme().drag_border)) .on_drag_move(cx.listener(Self::on_toolkit_text_drag_move)) .on_drop(cx.listener(Self::on_toolkit_text_drop)) @@ -247,10 +256,10 @@ impl Render for RichTextEditor { .left_0() .right_0() .bottom_0() - .child( + .when(!flow_cell_surface, |this| this.child( Scrollbar::vertical(&self.scroll_handle) .scrollbar_show(ScrollbarShow::Always) - ), + )), ) } } diff --git a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs index 67c5cbac..25b0c655 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/zoom.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/zoom.rs @@ -2,6 +2,31 @@ const MIN_ZOOM_PERCENT: f32 = 25.0; const MAX_ZOOM_PERCENT: f32 = 400.0; const ZOOM_STEP_PERCENT: f32 = 5.0; +#[derive(Clone, Copy)] +struct ZoomAnchorSnapshot { + target: ZoomAnchorTarget, + viewport_y_ratio: f32, + viewport_size: Size, + scroll_y: Pixels, + edit_generation: u64, + invisibility_mode: bool, +} + +#[derive(Clone, Copy)] +enum ZoomAnchorTarget { + Text { + offset: DocumentOffset, + line_y_ratio: f32, + }, + Block { + block_ix: usize, + block_y_ratio: f32, + }, + Document { + content_y_ratio: f32, + }, +} + #[hotpath::measure_all] impl RichTextEditor { pub fn zoom_percent(&self) -> f32 { @@ -13,12 +38,189 @@ impl RichTextEditor { if (self.zoom_percent - percent).abs() < f32::EPSILON { return; } + if self.zoom_anchor.is_none_or(|anchor| !self.zoom_anchor_is_current(anchor)) { + self.zoom_anchor = self.capture_zoom_anchor(); + } self.zoom_percent = percent; self.document.theme.zoom_factor = percent / 100.0; + self.zoom_anchor_apply_pending = true; self.invalidate_document_layout_caches(); cx.notify(); } + pub(super) fn prepare_pending_zoom_anchor(&mut self, width: Pixels, window: &mut Window, cx: &mut Context) { + if !self.zoom_anchor_apply_pending { + return; + } + let Some(ZoomAnchorSnapshot { + target: ZoomAnchorTarget::Text { offset, .. }, + .. + }) = self.zoom_anchor + else { + return; + }; + let _ = self.ensure_paragraph_chunk_containing_byte(offset.paragraph, offset.byte, width, window, cx); + } + + pub(super) fn apply_pending_zoom_anchor(&mut self) { + if !self.zoom_anchor_apply_pending { + return; + } + let Some(anchor) = self.zoom_anchor else { + self.zoom_anchor_apply_pending = false; + return; + }; + self.restore_preserved_zoom_anchor(Some(anchor)); + } + + pub(super) fn reset_zoom_anchor(&mut self) { + self.zoom_anchor = None; + self.zoom_anchor_apply_pending = false; + } + + fn active_zoom_anchor(&self) -> Option { + self.zoom_anchor.filter(|anchor| self.zoom_anchor_is_current(*anchor)) + } + + fn restore_preserved_zoom_anchor(&mut self, anchor: Option) { + let Some(mut anchor) = anchor else { + return; + }; + if self.restore_zoom_anchor(anchor) { + anchor.scroll_y = self.scroll_handle.offset().y; + self.zoom_anchor = Some(anchor); + self.zoom_anchor_apply_pending = false; + } + } + + fn zoom_anchor_is_current(&self, anchor: ZoomAnchorSnapshot) -> bool { + anchor.edit_generation == self.edit_generation + && anchor.invisibility_mode == self.invisibility_mode + && sizes_are_close(anchor.viewport_size, self.scroll_handle.bounds().size, px(0.5)) + && (anchor.scroll_y - self.scroll_handle.offset().y).abs() <= px(0.5) + } + + fn capture_zoom_anchor(&self) -> Option { + let viewport = self.scroll_handle.bounds(); + if viewport.size.height <= px(1.0) { + return None; + } + let viewport_y_ratio = 0.5; + let view_y = viewport.size.height * viewport_y_ratio; + let content_y = (-self.scroll_handle.offset().y + view_y).max(px(0.0)); + let valid_cache = self + .item_sizes_cache + .as_ref() + .filter(|cache| cache.item_count > 0 && self.height_prefix_index.len() == cache.item_count); + let total_height = valid_cache + .map_or_else(|| self.scroll_handle.content_size().height, |_| px(self.height_prefix_index.total_height())) + .max(px(1.0)); + let document_target = ZoomAnchorTarget::Document { + content_y_ratio: (content_y / total_height).clamp(0.0, 1.0), + }; + let Some(cache) = valid_cache else { + return Some(ZoomAnchorSnapshot { + target: document_target, + viewport_y_ratio, + viewport_size: viewport.size, + scroll_y: self.scroll_handle.offset().y, + edit_generation: self.edit_generation, + invisibility_mode: self.invisibility_mode, + }); + }; + let item_ix = self.height_prefix_index.lower_bound(content_y); + let target = match cache.items.get(item_ix) { + Some(VirtualItem::ParagraphChunk { .. } | VirtualItem::ParagraphRemainder { .. }) => { + let position = point(viewport.left() + viewport.size.width / 2.0, viewport.top() + view_y); + self.hit_test_cached_position(position).map_or(document_target, |offset| ZoomAnchorTarget::Text { + offset, + line_y_ratio: self + .zoom_text_line_y_ratio(offset, content_y) + .unwrap_or(0.5), + }) + }, + Some(VirtualItem::StructuralBlock { block_ix }) => { + let item_top = self.height_prefix_index.item_top(item_ix); + let item_height = cache + .sizes + .get(item_ix) + .map(|size| size.height) + .unwrap_or(px(1.0)) + .max(px(1.0)); + ZoomAnchorTarget::Block { + block_ix: *block_ix, + block_y_ratio: ((content_y - item_top) / item_height).clamp(0.0, 1.0), + } + }, + Some(VirtualItem::HiddenBlock { .. }) | None => document_target, + }; + Some(ZoomAnchorSnapshot { + target, + viewport_y_ratio, + viewport_size: viewport.size, + scroll_y: self.scroll_handle.offset().y, + edit_generation: self.edit_generation, + invisibility_mode: self.invisibility_mode, + }) + } + + fn zoom_text_line_y_ratio(&self, offset: DocumentOffset, content_y: Pixels) -> Option { + let width = self.current_layout_width(); + let (chunk_ix, layout) = self.paragraph_chunk_containing_byte(offset.paragraph, offset.byte, width)?; + let item_top = self.item_top_for_paragraph_chunk(offset.paragraph, chunk_ix)?; + let caret = caret_bounds(&layout, offset, VisualGravity::Neutral, point(px(0.0), item_top))?; + Some(relative_line_y(content_y, caret.top(), caret.size.height)) + } + + fn zoom_text_anchor_content_y(&self, offset: DocumentOffset, line_y_ratio: f32) -> Option { + let width = self.current_layout_width(); + let (chunk_ix, layout) = self.paragraph_chunk_containing_byte(offset.paragraph, offset.byte, width)?; + let item_top = self.item_top_for_paragraph_chunk(offset.paragraph, chunk_ix)?; + let caret = caret_bounds(&layout, offset, VisualGravity::Neutral, point(px(0.0), item_top))?; + Some(line_y_from_relative(caret.top(), caret.size.height, line_y_ratio)) + } + + fn restore_zoom_anchor(&mut self, anchor: ZoomAnchorSnapshot) -> bool { + let viewport = self.scroll_handle.bounds(); + if viewport.size.height <= px(1.0) || self.height_prefix_index.len() == 0 { + return false; + } + let total_height = px(self.height_prefix_index.total_height()); + let target_y = match anchor.target { + ZoomAnchorTarget::Text { offset, line_y_ratio } => { + let Some(target_y) = self.zoom_text_anchor_content_y(offset, line_y_ratio) else { + return false; + }; + target_y + }, + ZoomAnchorTarget::Block { + block_ix, + block_y_ratio, + } => { + let Some(cache) = &self.item_sizes_cache else { + return false; + }; + let Some(block_top) = self.block_top_for_index(block_ix) else { + return false; + }; + let Some(block_height) = cache.block_heights.get(block_ix).copied() else { + return false; + }; + block_top + block_height * block_y_ratio + }, + ZoomAnchorTarget::Document { content_y_ratio } => total_height * content_y_ratio, + }; + let mut offset = self.scroll_handle.offset(); + offset.y = -anchored_scroll_top( + target_y, + viewport.size.height * anchor.viewport_y_ratio, + total_height, + viewport.size.height, + ); + self.scroll_handle.set_offset(offset); + true + } + fn zoom_by(&mut self, delta_percent: f32, cx: &mut Context) { self.set_zoom_percent(self.zoom_percent + delta_percent, cx); } @@ -31,3 +233,76 @@ impl RichTextEditor { self.zoom_by(-ZOOM_STEP_PERCENT, cx); } } + +fn anchored_scroll_top(target_y: Pixels, viewport_y: Pixels, total_height: Pixels, viewport_height: Pixels) -> Pixels { + let max_scroll_top = (total_height - viewport_height).max(px(0.0)); + (target_y - viewport_y).max(px(0.0)).min(max_scroll_top) +} + +fn relative_line_y(content_y: Pixels, line_top: Pixels, line_height: Pixels) -> f32 { + (content_y - line_top) / line_height.max(px(1.0)) +} + +fn line_y_from_relative(line_top: Pixels, line_height: Pixels, relative_y: f32) -> Pixels { + line_top + line_height.max(px(1.0)) * relative_y +} + +fn sizes_are_close(left: Size, right: Size, tolerance: Pixels) -> bool { + (left.width - right.width).abs() <= tolerance && (left.height - right.height).abs() <= tolerance +} + +#[cfg(test)] +mod zoom_tests { + use super::*; + + #[test] + fn semantic_anchor_round_trips_through_short_intermediate_layouts() { + let viewport_height = px(600.0); + let viewport_y = viewport_height / 2.0; + let original_target = px(1500.0); + let original_scroll = anchored_scroll_top(original_target, viewport_y, px(3000.0), viewport_height); + + assert_eq!(original_scroll, px(1200.0)); + assert_eq!( + anchored_scroll_top(px(150.0), viewport_y, px(300.0), viewport_height), + px(0.0) + ); + assert_eq!( + anchored_scroll_top(original_target, viewport_y, px(3000.0), viewport_height), + original_scroll + ); + } + + #[test] + fn anchor_clamps_only_against_the_new_layout_extent() { + assert_eq!( + anchored_scroll_top(px(1900.0), px(300.0), px(2000.0), px(600.0)), + px(1400.0) + ); + } + + #[test] + fn semantic_line_anchor_preserves_positions_outside_the_line_box() { + let line_top = px(100.0); + let line_height = px(20.0); + + for content_y in [px(92.0), px(110.0), px(129.0)] { + let relative_y = relative_line_y(content_y, line_top, line_height); + assert_eq!(line_y_from_relative(line_top, line_height, relative_y), content_y); + } + } + + #[test] + fn semantic_anchor_is_invalidated_when_viewport_geometry_changes() { + assert!(sizes_are_close( + size(px(900.0), px(600.0)), + size(px(900.25), px(599.75)), + px(0.5), + )); + assert!(!sizes_are_close( + size(px(900.0), px(600.0)), + size(px(1100.0), px(600.0)), + px(0.5), + )); + } +} diff --git a/crates/gpui-flowtext/src/rich_text/element.rs b/crates/gpui-flowtext/src/rich_text/element.rs index f9af322e..8eb72b89 100644 --- a/crates/gpui-flowtext/src/rich_text/element.rs +++ b/crates/gpui-flowtext/src/rich_text/element.rs @@ -284,10 +284,14 @@ impl Element for VirtualParagraphChunkElement { .then_some(editor.selection.head), editor.caret_paint_width(), editor.local_caret_color_rgb(), - // The caret's fallback color = the theme's default text color, so it - // contrasts with the background (a hardcoded black caret is invisible on a - // dark theme). - editor.document.theme.default_text_color, + // The caret's fallback color = the configured caret color (flow cells + // tint the caret to the cell text color) or the theme's default text + // color, so it contrasts with the background (a hardcoded black caret + // is invisible on a dark theme). + editor + .config() + .caret_color + .unwrap_or(editor.document.theme.default_text_color), external_carets, external_selections, annotation_selections, @@ -302,7 +306,15 @@ impl Element for VirtualParagraphChunkElement { let collapse_state = self .editor .read(cx) - .section_collapse_state_at_paragraph(self.paragraph_ix, &[0, 1, 2, 3]); + .config() + .show_section_collapse_controls + .then(|| { + self + .editor + .read(cx) + .section_collapse_state_at_paragraph(self.paragraph_ix, &[0, 1, 2, 3]) + }) + .flatten(); if let Some(collapsed) = collapse_state { let indicator_width = px(9.0); let indicator_height = if collapsed { px(2.0) } else { px(9.0) }; diff --git a/crates/gpui-flowtext/src/rich_text/invisibility.rs b/crates/gpui-flowtext/src/rich_text/invisibility.rs index 429080dd..c412f442 100644 --- a/crates/gpui-flowtext/src/rich_text/invisibility.rs +++ b/crates/gpui-flowtext/src/rich_text/invisibility.rs @@ -66,16 +66,8 @@ pub(super) fn paragraph_is_visible(document: &DocumentProjection, paragraph: &Pa #[hotpath::measure] pub(super) fn paragraph_is_visible_for_theme(theme: &DocumentTheme, paragraph: &Paragraph) -> bool { - match paragraph.style { - ParagraphStyle::Normal => {}, - ParagraphStyle::Custom(slot) - if theme - .invisibility_visible_paragraph_styles - .contains(&(slot & 0x7f)) => - { - return true; - }, - ParagraphStyle::Custom(_) => {}, + if paragraph_style_is_visible_for_theme(theme, paragraph.style) { + return true; } paragraph .runs @@ -83,12 +75,19 @@ pub(super) fn paragraph_is_visible_for_theme(theme: &DocumentTheme, paragraph: & .any(|run| run_is_visible_for_theme(theme, run.styles)) } +pub(super) fn paragraph_style_is_visible_for_theme(theme: &DocumentTheme, style: ParagraphStyle) -> bool { + matches!( + style, + ParagraphStyle::Custom(slot) if theme.invisibility_visible_paragraph_styles.contains(&(slot & 0x7f)) + ) +} + pub(super) const INVISIBILITY_PROJECTED_VERSION_OFFSET: u64 = 0x9E37_79B9_7F4A_7C15; #[hotpath::measure] pub(super) fn invisibility_projected_document(document: &DocumentProjection, paragraph_ix: usize) -> Option { let paragraph = document.paragraphs.get(paragraph_ix)?; - if !matches!(paragraph.style, ParagraphStyle::Normal) { + if paragraph_style_is_visible_for_theme(&document.theme, paragraph.style) { return None; } diff --git a/crates/gpui-flowtext/src/rich_text/layout/prep.rs b/crates/gpui-flowtext/src/rich_text/layout/prep.rs index c9b74e24..02388ee3 100644 --- a/crates/gpui-flowtext/src/rich_text/layout/prep.rs +++ b/crates/gpui-flowtext/src/rich_text/layout/prep.rs @@ -189,10 +189,10 @@ pub(super) fn build_paragraph_prep_from_parts( invisibility_mode, }; - if invisibility_mode && matches!(paragraph.style, ParagraphStyle::Normal) { + if invisibility_mode && !paragraph_style_is_visible_for_theme(theme, paragraph.style) { let Some((text, runs)) = projected_visible_paragraph_text_and_runs_from_text(text, theme, paragraph, paragraph_byte_range.clone()) else { - // A Normal paragraph with no visible (cite/spoken/...) runs has nothing to - // project, so it is hidden rather than rendered as a blank visible line. + // A paragraph whose style is hidden and has no visible runs has nothing + // to project, so it is hidden rather than rendered as a blank line. return Some(ParagraphPrep { key, paragraph_id, @@ -378,7 +378,7 @@ mod prep_tests { #[test] #[hotpath::measure] - fn invisibility_prep_projects_visible_runs() { + fn invisibility_prep_projects_visible_runs_from_hidden_custom_paragraphs() { let cite = RunStyles { semantic: RunSemanticStyle::Custom(1), ..RunStyles::default() @@ -393,7 +393,7 @@ mod prep_tests { let document = document_from_input( theme, vec![InputParagraph { - style: ParagraphStyle::Normal, + style: ParagraphStyle::Custom(9), runs: vec![ input_run("hidden ", RunStyles::default()), input_run("cite", cite), diff --git a/flow-ergonomics-fixture.annotated.fl0.corrupt.c6681a35-97cc-4d2a-9417-5ea172057cc1 b/flow-ergonomics-fixture.annotated.fl0.corrupt.c6681a35-97cc-4d2a-9417-5ea172057cc1 new file mode 100644 index 00000000..5e7ec445 Binary files /dev/null and b/flow-ergonomics-fixture.annotated.fl0.corrupt.c6681a35-97cc-4d2a-9417-5ea172057cc1 differ diff --git a/flow-ergonomics-fixture.fl0.corrupt.5e53ec12-30c5-4aff-a2d6-e51ceb4ca4e3 b/flow-ergonomics-fixture.fl0.corrupt.5e53ec12-30c5-4aff-a2d6-e51ceb4ca4e3 new file mode 100644 index 00000000..5a4ae87e Binary files /dev/null and b/flow-ergonomics-fixture.fl0.corrupt.5e53ec12-30c5-4aff-a2d6-e51ceb4ca4e3 differ diff --git a/flow-ergonomics-guide.md b/flow-ergonomics-guide.md new file mode 100644 index 00000000..d90cf4d9 --- /dev/null +++ b/flow-ergonomics-guide.md @@ -0,0 +1,117 @@ +# Flow drag-and-drop ergonomics test (run straight through) + +A fixed battery of drag targets, paired with drag telemetry, to turn "a lot of moves I couldn't get +across" into specific, located problems. + +## 1. Setup + +Launch with logging on and the fixture open: + +```fish +rm -f ~/flow-drag-log.jsonl; env FLOWSTATE_DRAG_LOG=$HOME/flow-drag-log.jsonl cargo run --release -p flowstate --manifest-path /home/adam/Projects/flowstate-fixflow-collab/Cargo.toml -- /home/adam/Projects/flowstate-fixflow-collab/flow-ergonomics-fixture.fl0 +``` + +Each **drag** appends one JSON line to `~/flow-drag-log.jsonl`. Send me that file when done. + +## 2. The reset protocol (this is what lets you run straight through) + +Every step below starts from the **fresh** layout in the map. To get back there after each step: + +> **After each step, press the ribbon's `Undo` button once per move you made this step**, until the +> board matches the map again. (Do NOT save.) + +- A move that "didn't take" (no change) has nothing to undo — you're already fresh. +- If you attempted a step several times, Undo that many times. +- If the board ever looks wrong or Undo won't go further, **hard-reset**: close the tab and reopen + `flow-ergonomics-fixture.fl0` (don't save first). Telemetry keeps logging across reopens. + +Undo is *not* part of the test — only your drags are logged, so undoing between steps costs us nothing. + +## 3. Board map — the "Ergonomics" sheet (every step starts here) + +Columns left → right: **1AC · 1NC · 2AC · Block · 1AR · 2NR · 2AR** + +``` +1AC 1NC 2AC Block 1AR 2NR +A1 ──┬── B1 ─────── C1 ─────── D1 ──── E1 ── F1 + └── B2 +A2 ───── B3 ──┬─── C2 + └─── C3 +A3 ───── B5 + B4 (orphan, no parent) + C4 (orphan, no parent) +``` + +- Chain: `A1→B1→C1→D1→E1→F1`. Also `A2→B3→{C2,C3}` and `A3→B5`. +- `B4` (in 1NC) and `C4` (in 2AC) are parentless orphans. +- Heights vary on purpose. There's a throwaway second sheet **Scratch** if you want extra room. + +For each step: **Move** = what to do · **Expect** = where it lands (from the fresh map) · **Watch** = +the feel to judge. Then **↩ reset** and go to the next. + +## 4. The battery + +1. **Reorder siblings.** Move **B2** above **B1**---feilt weird + *Expect:* under A1, order becomes B2, B1 (both still 1NC responses to A1). *Watch:* landing "just + above B1" precisely. **↩ Undo once.** + +2. **Reorder roots.** Move **A3** to the very top of 1AC (above A1). + *Expect:* 1AC reads A3, A1, A2. *Watch:* dropping at the very top of a column. **↩ Undo once.** + +3. **Demote a root to a child (rightward).** Drop **A2** onto **A1** as its last response.---EXTREMELY DIFFICULT AND NARROW + *Expect:* A2 becomes A1's last 1NC child; its subtree B3→2AC, C2/C3→Block. *Watch:* the right-edge + "make child" zone — findable? triggers when you mean it? **↩ Undo once.** + +4. **First-child insert.** Make **A3** the **first** response of **A1**.---did something weird, moved entire childset down a lot, no natural gap between it and second child + *Expect:* A3 is A1's first 1NC child, above B1/B2. *Watch:* first vs last child. **↩ Undo once.** + +5. **Promote a child to a root (leftward).** Move **C1** into 1AC, dropped between A1 and A2. + *Expect:* C1 root in 1AC between A1 and A2; D1→1NC, E1→2AC, F1→Block (chain preserved). *Watch:* can + you place it at the vertical spot you aim at? **↩ Undo once.** + +6. **Reparent a subtree.** Move **B3** (carrying C2/C3) to be a response to **A1**. + *Expect:* B3 becomes A1's child; C2/C3 stay B3's children in 2AC. *Watch:* subtree stays intact and + follows the pointer. **↩ Undo once.** + +7. **Insert into the middle of a sibling run.** Move **C4** (orphan) to sit **between C2 and C3** under + B3. + *Expect:* 2AC under B3 reads C2, C4, C3 (C4 now B3's child). *Watch:* precise mid-run insertion. + **↩ Undo once.** + +8. **Orphan → response.** Move **B4** (orphan) to be a response to **A1**, above B2. + *Expect:* B4 becomes a 1NC child of A1, above B2 (it should *inherit* A1 as parent). *Watch:* does + dropping next to a parented cell adopt that parent? **↩ Undo once.** + +9. **Reach the far-right column.** Move **F1** to be a standalone root in the last column, **2AR**. + *Expect:* F1 is a parentless root in 2AR. *Watch:* can you target/reach the rightmost column? + **↩ Undo once.** + +10. **Standalone at top of a column.** Move **C3** to the very top of 2AC as a standalone (no parent).---weird, same drop and no gap effect as earlier + *Expect:* C3 is parentless at the top of 2AC. *Watch:* standalone drop vs auto-adopting a parent. + **↩ Undo once.** + +11. **First-child of a subtree, multi-level shift.** Make **C1** (with its chain) the **first**---same weirdness + response of **A2**. + *Expect:* C1→1NC (first child of A2), D1→2AC, E1→Block, F1→1AR. *Watch:* the whole chain shifting + columns together. **↩ Undo once.** + +12. **Invalid move (should refuse).** Try to drop **A1** onto its own descendant (**B1** or **C1**) as + a child. + *Expect:* **nothing** — no landing preview, no move. *Watch:* does it feel clearly "not allowed"? + **↩ Nothing to undo** (it shouldn't have moved). + +13. **Long move + auto-scroll.** Scroll so the top-left and bottom-right aren't both visible, then drag + **F1** all the way to above **A1**. + *Expect:* F1 becomes a root in 1AC above A1. *Watch:* does edge auto-scroll carry you there? + **↩ Undo once** (scroll back up first if needed). + +14. **Free-for-all.** From fresh, do 3–5 moves that *should* be easy — especially any that beat you + last time. Attempt each; the log records every attempt and where the pointer actually was. Undo + between them if you like.---woah B3 child of B2 caused C1 child of B1 to go BELOW B3? That's not the hierarchy at all... + +## 5. Sending results + +Send me `~/flow-drag-log.jsonl`. A one-line note per step number where the feel was off ("#5 still +snaps", "#9 couldn't reach 2AR") plus the offsets makes the diagnosis fast. Anything you'd rather +show than describe, type into the relevant cell and save a copy — I can read notes straight out of the +`.fl0`. diff --git a/flowplan.md b/flowplan.md new file mode 100644 index 00000000..c6852dd7 --- /dev/null +++ b/flowplan.md @@ -0,0 +1,45 @@ +• I can identify several concrete causes from the code. Visual data would help prioritize them, but is not required to establish that the current implementation is structurally unreliable. + + Primary Issues + + 1. Nested drop targets fight each other + - Every cell and its containing column have on_drag_move and on_drop. + - Cell drag-move stops propagation, but column behavior still wins whenever the pointer crosses small gaps, controls, or card edges. + - pending_cell_drop constantly switches between cell-relative and orphan-column destinations. + + 2. No drag-leave cleanup + - pending_cell_drop remains set after leaving a valid target. + - Dropping later can execute the last stale destination rather than the visible/current target. + + 3. Drop zones are too coarse + - A cell’s rightmost 28% means “child.” + - Everything else is split at one vertical midpoint. + - This makes intended top/bottom drops difficult on short cards and child drops easy to trigger accidentally. + + 4. Column insertion uses global sheet indexes incorrectly + - update_column_drop derives an index from cells in one column, then uses the corresponding global sheet index. + - Because sheet order mixes families and columns, the destination can differ substantially from the pointer’s visible vertical location. + + 5. Gap inheritance is inconsistent + - A gap becomes a sibling drop only when adjacent same-column cells share a parent. + - Otherwise it becomes an orphan, even when visually inside a family run. + + 6. Moving a parent destroys its existing family + - move_cell unconditionally orphans all direct children. + - Dragging a parent therefore moves only the parent, causing large unexpected layout changes. + + 7. No invalid-drop validation before preview + - The UI can preview child drops onto descendants, structurally invalid columns, or destinations that effectively do nothing. + - Failure occurs silently when the model rejects or normalizes it. + + 8. No explicit drop indicator geometry + - Only the target card border changes. + - Users cannot reliably distinguish before, after, child, or orphan insertion positions. + + 9. Every drag movement triggers a full render + - update_cell_drop always assigns and calls cx.notify(), even when the destination did not change. + - This contributes to dodgy responsiveness. + + So yes, the implementation itself explains the general unreliability. Visual examples would only help confirm which behavior is most disruptive. + +1. Ok, the zoom is still not maintaining fidelity, but I think I have visual data that might help pin the problem down. For the flow, if I zoom out to minimum, then zoom in as fast as I can, I go to the far far right of the flow when zoomed in. But if I zoom in slower, I go left of that---and the slower I go, the more left I end up. At the absolutely slowest I tried, the screen found the rough midpoint. 2. In the middle of the zooming process there's a lot of wobbly snapping back and forth where elements on the screen will tick their position between two seeming states rather than smoothly scaling. 3. For the dot grid, they sort of seem to change position or shift around as zoom occurs, but I can't pin down the rule it follows---but as I zoom in, the dots get fainter and less defined, and as I zoom out, they become pronounced and very defined---logically, the opposite should happen---zooming on the dots should make them bigger. I presume this is because the dots are point-like, and they just represent as points on whatever the given zoom is? Or is it a more complex explanation? Regardless, the dot sizing at 100% zoom is perfect, it would be great if that scaled with the zoom logically. diff --git a/message-square-reply.svg b/message-square-reply.svg new file mode 100644 index 00000000..7c33d195 --- /dev/null +++ b/message-square-reply.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/paniconwritetopdf.md b/paniconwritetopdf.md new file mode 100644 index 00000000..e69de29b diff --git a/vendor/gpui-component/src/icon.rs b/vendor/gpui-component/src/icon.rs index b6867a43..0d8f5921 100644 --- a/vendor/gpui-component/src/icon.rs +++ b/vendor/gpui-component/src/icon.rs @@ -89,6 +89,7 @@ pub enum IconName { Redo, Redo2, Replace, + ReplaceAll, ResizeCorner, Search, Settings, @@ -99,6 +100,7 @@ pub enum IconName { Star, StarOff, Sun, + TextSearch, ThumbsDown, ThumbsUp, TriangleAlert, @@ -187,6 +189,7 @@ impl IconNamed for IconName { Self::Redo => "icons/redo.svg", Self::Redo2 => "icons/redo-2.svg", Self::Replace => "icons/replace.svg", + Self::ReplaceAll => "icons/replace-all.svg", Self::ResizeCorner => "icons/resize-corner.svg", Self::Search => "icons/search.svg", Self::Settings => "icons/settings.svg", @@ -197,6 +200,7 @@ impl IconNamed for IconName { Self::Star => "icons/star.svg", Self::StarOff => "icons/star-off.svg", Self::Sun => "icons/sun.svg", + Self::TextSearch => "icons/text-search.svg", Self::ThumbsDown => "icons/thumbs-down.svg", Self::ThumbsUp => "icons/thumbs-up.svg", Self::TriangleAlert => "icons/triangle-alert.svg", diff --git a/vendor/gpui-component/src/menu/dropdown_menu.rs b/vendor/gpui-component/src/menu/dropdown_menu.rs index 308f5cb9..b2fd5994 100644 --- a/vendor/gpui-component/src/menu/dropdown_menu.rs +++ b/vendor/gpui-component/src/menu/dropdown_menu.rs @@ -5,7 +5,12 @@ use gpui::{ RenderOnce, SharedString, StyleRefinement, Styled, Window, }; -use crate::{button::Button, menu::PopupMenu, popover::Popover, Selectable}; +use crate::{ + button::Button, + menu::{MenuChangedEvent, PopupMenu}, + popover::Popover, + Selectable, +}; /// A dropdown menu trait for buttons and other interactive elements pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { @@ -128,6 +133,17 @@ where }) .detach(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &MenuChangedEvent, _window, cx| { + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + menu.clone() } }; diff --git a/vendor/gpui-component/src/menu/mod.rs b/vendor/gpui-component/src/menu/mod.rs index 3152a15c..026af60e 100644 --- a/vendor/gpui-component/src/menu/mod.rs +++ b/vendor/gpui-component/src/menu/mod.rs @@ -9,7 +9,7 @@ mod popup_menu; pub use app_menu_bar::AppMenuBar; pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState}; pub use dropdown_menu::DropdownMenu; -pub use popup_menu::{PopupMenu, PopupMenuItem}; +pub use popup_menu::{MenuChangedEvent, PopupMenu, PopupMenuItem}; pub(crate) fn init(cx: &mut App) { app_menu_bar::init(cx); diff --git a/vendor/gpui-component/src/menu/popup_menu.rs b/vendor/gpui-component/src/menu/popup_menu.rs index 3a4ea3e9..a7741d8f 100644 --- a/vendor/gpui-component/src/menu/popup_menu.rs +++ b/vendor/gpui-component/src/menu/popup_menu.rs @@ -11,6 +11,10 @@ use gpui::{ WeakEntity, Window, anchored, canvas, div, prelude::FluentBuilder, px, rems, }; use gpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Subscription}; + +#[derive(Clone, Copy)] +pub struct MenuChangedEvent; + use std::rc::Rc; const CONTEXT: &str = "PopupMenu"; @@ -38,6 +42,8 @@ pub enum PopupMenuItem { label: SharedString, disabled: bool, checked: bool, + keep_open: bool, + radio: bool, is_link: bool, action: Option>, // For link item @@ -49,6 +55,8 @@ pub enum PopupMenuItem { icon: Option, disabled: bool, checked: bool, + keep_open: bool, + radio: bool, action: Option>, render: Box AnyElement + 'static>, handler: Option>, @@ -61,6 +69,7 @@ pub enum PopupMenuItem { icon: Option, label: SharedString, disabled: bool, + checked: bool, menu: Entity, }, } @@ -75,6 +84,8 @@ impl PopupMenuItem { label: label.into(), disabled: false, checked: false, + keep_open: false, + radio: false, action: None, is_link: false, handler: None, @@ -93,6 +104,8 @@ impl PopupMenuItem { icon: None, disabled: false, checked: false, + keep_open: false, + radio: false, action: None, render: Box::new(move |window, cx| builder(window, cx).into_any_element()), handler: None, @@ -107,6 +120,7 @@ impl PopupMenuItem { icon: None, label: label.into(), disabled: false, + checked: false, menu, } } @@ -188,6 +202,9 @@ impl PopupMenuItem { PopupMenuItem::ElementItem { checked: c, .. } => { *c = checked; } + PopupMenuItem::Submenu { checked: c, .. } => { + *c = checked; + } _ => {} } self @@ -212,6 +229,34 @@ impl PopupMenuItem { self } + /// Keep the menu open after clicking this item. + /// + /// Useful for checkbox items in filter menus where the user may want + /// to toggle multiple options without the menu closing after each one. + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn keep_open(mut self, keep: bool) -> Self { + match &mut self { + PopupMenuItem::Item { keep_open, .. } => *keep_open = keep, + PopupMenuItem::ElementItem { keep_open, .. } => *keep_open = keep, + _ => {} + } + self + } + + /// Make this item behave as a radio button — when clicked with + /// [`keep_open`](Self::keep_open), all other items in the same menu + /// are unchecked first. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn radio(mut self, is_radio: bool) -> Self { + match &mut self { + PopupMenuItem::Item { radio, .. } => *radio = is_radio, + PopupMenuItem::ElementItem { radio, .. } => *radio = is_radio, + _ => {} + } + self + } + /// Add a hover handler for the menu item. /// /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. @@ -244,6 +289,8 @@ impl PopupMenuItem { label: label.into(), disabled: false, checked: false, + keep_open: false, + radio: false, action: None, is_link: true, handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))), @@ -282,7 +329,9 @@ impl PopupMenuItem { PopupMenuItem::ElementItem { icon, checked, .. } => { icon.is_some() || (check_side.is_left() && *checked) } - PopupMenuItem::Submenu { icon, .. } => icon.is_some(), + PopupMenuItem::Submenu { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } _ => false, } } @@ -292,6 +341,7 @@ impl PopupMenuItem { match self { PopupMenuItem::Item { checked, .. } => *checked, PopupMenuItem::ElementItem { checked, .. } => *checked, + PopupMenuItem::Submenu { checked, .. } => *checked, _ => false, } } @@ -311,7 +361,7 @@ pub struct PopupMenu { check_side: Side, /// The parent menu of this menu, if this is a submenu - parent_menu: Option>, + pub parent_menu: Option>, scrollable: bool, scrollbar_show: Option, external_link_icon: bool, @@ -769,7 +819,11 @@ impl PopupMenu { let item = self.menu_items.get(index); match item { Some(PopupMenuItem::Item { - handler, action, .. + handler, + action, + keep_open, + radio, + .. }) => { if let Some(handler) = handler { handler(&ClickEvent::default(), window, cx); @@ -777,17 +831,63 @@ impl PopupMenu { self.dispatch_confirm_action(action, window, cx); } - self.dismiss(&Cancel, window, cx) + if *keep_open { + if *radio { + for item in self.menu_items.iter_mut() { + if let PopupMenuItem::Item { checked, .. } = item { + *checked = false; + } + } + if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::Item { checked, .. } = item { + *checked = true; + } + } + } else if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::Item { checked, .. } = item { + *checked = !*checked; + } + } + cx.notify(); + self.keep_open_confirmed(window, cx); + } else { + self.dismiss(&Cancel, window, cx) + } } Some(PopupMenuItem::ElementItem { - handler, action, .. + handler, + action, + keep_open, + radio, + .. }) => { if let Some(handler) = handler { handler(&ClickEvent::default(), window, cx); } else if let Some(action) = action.as_ref() { self.dispatch_confirm_action(action, window, cx); } - self.dismiss(&Cancel, window, cx) + if *keep_open { + if *radio { + for item in self.menu_items.iter_mut() { + if let PopupMenuItem::ElementItem { checked, .. } = item { + *checked = false; + } + } + if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::ElementItem { checked, .. } = item { + *checked = true; + } + } + } else if let Some(item) = self.menu_items.get_mut(index) { + if let PopupMenuItem::ElementItem { checked, .. } = item { + *checked = !*checked; + } + } + cx.notify(); + self.keep_open_confirmed(window, cx); + } else { + self.dismiss(&Cancel, window, cx) + } } _ => {} } @@ -977,6 +1077,10 @@ impl PopupMenu { }); } + pub(crate) fn keep_open_confirmed(&mut self, _: &mut Window, cx: &mut Context) { + cx.notify(); + } + fn render_key_binding( &self, action: Option>, @@ -1204,6 +1308,7 @@ impl PopupMenu { label, menu, disabled, + .. } => this .selected(selected) .disabled(*disabled) @@ -1216,7 +1321,7 @@ impl PopupMenu { .gap_x_1() .children(Self::render_icon( has_left_icon, - false, + options.check_side.is_left() && item.is_checked(), icon.clone(), window, cx, @@ -1228,6 +1333,7 @@ impl PopupMenu { .items_center() .justify_between() .child(label.clone()) + .children(right_check_icon.map(|icon| icon.ml_3())) .child(IconName::ChevronRight), ), ) @@ -1256,6 +1362,7 @@ impl PopupMenu { impl FluentBuilder for PopupMenu {} impl EventEmitter for PopupMenu {} +impl EventEmitter for PopupMenu {} impl Focusable for PopupMenu { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone()