diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index f0632c505..d1153b4e9 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -576,9 +576,7 @@ pub const step_format = 1; /// Report a step of this document's history to whoever records it (`sdk.Host.recordStep`), when /// anyone does: nothing is encoded otherwise. fn recordStep(self: *const Document, step: sdk.Step) void { - const host = sdk.host(); - if (!host.recording(self.id)) return; - host.recordStep(self.id, step); + sdk.host().recordStep(self.id, step); } /// What `history.end` or `pushComplete` did, reported as a step. @@ -589,7 +587,9 @@ pub fn recordCommitted(self: *const Document, c: tc.History.Committed) void { /// `edits` of history step `id` reported as a step, forward or (`reverse`) as their undo, `sel` /// the selection they leave. fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool) void { - if (!sdk.host().recording(self.id)) return; + // Every step reaches the host, which keeps who made it; its bytes only while it is recorded. + const top: u64 = if (self.history.lastApplied()) |r| r.step else 0; + if (!sdk.host().recording(self.id)) return self.recordStep(.{ .kind = kind, .id = id, .top = top }); const gpa = sdk.allocator(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); @@ -597,7 +597,7 @@ fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []con std.log.err("text: a step of {s} was not recorded: out of memory", .{self.path}); return; }; - self.recordStep(.{ .kind = kind, .id = id, .effect = out.written() }); + self.recordStep(.{ .kind = kind, .id = id, .effect = out.written(), .top = top }); } /// Apply a recorded step (`step_format`) as a new step of this document's history: the edits diff --git a/plugins/text/src/TextEditor.zig b/plugins/text/src/TextEditor.zig index b26450361..fcc52121e 100644 --- a/plugins/text/src/TextEditor.zig +++ b/plugins/text/src/TextEditor.zig @@ -1831,7 +1831,13 @@ fn editNotifyInserted(ctx: *anyopaque, pos: usize, bytes: []const u8) void { } fn editNotifyEnd(ctx: *anyopaque, sel_after: tc.Range) void { const doc = docFromCtx(ctx); - if (doc.history.end(sdk.allocator(), sel_after)) |c| doc.recordCommitted(c); + // One hand's typing never joins another's run (`sdk.Host.mayMerge`). + const host = sdk.host(); + const may_join = if (doc.history.lastApplied()) |top| host.mayMerge(doc.id, top.step) else true; + if (doc.history.end(sdk.allocator(), sel_after, may_join)) |c| { + doc.recordCommitted(c); + host.documentEdited(doc.id); + } } const max_text_bytes: usize = 64 * 1024 * 1024; diff --git a/plugins/text/src/textcore/History.zig b/plugins/text/src/textcore/History.zig index db242a585..5f00dbebb 100644 --- a/plugins/text/src/textcore/History.zig +++ b/plugins/text/src/textcore/History.zig @@ -223,9 +223,11 @@ pub fn lastUndone(self: *const History) ?StepRef { return if (items.len == 0) null else .of(&items[items.len - 1]); } -/// Commit the pending edit, merging it into the current group when VSCode's rule says to. Returns -/// what it did, or null when there was nothing to commit. -pub fn end(self: *History, gpa: Allocator, sel_after: Range) ?Committed { +/// Commit the pending edit, merging it into the current group when VSCode's rule says to and +/// `may_join` allows: false when the step on top is another hand's (`sdk.Host.mayMerge`), so one +/// hand's typing never joins another's run. Returns what it did, or null when there was nothing to +/// commit. +pub fn end(self: *History, gpa: Allocator, sel_after: Range, may_join: bool) ?Committed { var p = self.pending orelse return null; self.pending = null; @@ -261,7 +263,7 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) ?Committed { self.clearRedo(gpa); - const can_merge = self.group_open and + const can_merge = may_join and self.group_open and self.undo_stack.items.len > 0 and !shouldBreakGroup(prev_kind, kind); @@ -473,7 +475,7 @@ const Harness = struct { try self.text.insertSlice(self.gpa, self.cursor, ch); self.hist.note(self.gpa, self.cursor, "", ch); self.cursor += ch.len; - _ = self.hist.end(self.gpa, .collapsed(self.cursor)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor), true); } } @@ -485,7 +487,7 @@ const Harness = struct { self.hist.note(self.gpa, self.cursor - 1, removed, ""); try self.text.replaceRange(self.gpa, self.cursor - 1, 1, ""); self.cursor -= 1; - _ = self.hist.end(self.gpa, .collapsed(self.cursor)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor), true); } } @@ -584,7 +586,7 @@ test "undo restores the selection, not just an offset" { hist.begin(.init(6, 11)); hist.note(a, 6, "world", "X"); try text.replaceRange(a, 6, 5, "X"); - _ = hist.end(a, .collapsed(7)); + _ = hist.end(a, .collapsed(7), true); const sel = hist.applyUndo(a, &text).?; try t.expectEqualStrings("hello world", text.items); @@ -673,7 +675,7 @@ test "cut-shaped deletion is one undoable step" { h.hist.note(h.gpa, 6, "world", ""); try h.text.replaceRange(h.gpa, 6, 5, ""); h.cursor = 6; - _ = h.hist.end(h.gpa, .collapsed(6)); + _ = h.hist.end(h.gpa, .collapsed(6), true); try t.expectEqualStrings("hello ", h.text.items); _ = h.undo(); @@ -716,13 +718,13 @@ test "a commit says whether it started a step or joined one, and what it added" hist.begin(.collapsed(0)); hist.note(a, 0, "", "a"); - const first = hist.end(a, .collapsed(1)).?; + const first = hist.end(a, .collapsed(1), true).?; try t.expect(!first.joined); try t.expectEqual(@as(usize, 1), first.edits.len); hist.begin(.collapsed(1)); hist.note(a, 1, "", "b"); - const second = hist.end(a, .collapsed(2)).?; + const second = hist.end(a, .collapsed(2), true).?; // Typing runs on: the same step, and only the new keystroke. try t.expect(second.joined); try t.expectEqual(first.step, second.step); @@ -731,7 +733,7 @@ test "a commit says whether it started a step or joined one, and what it added" // An empty commit commits nothing. hist.begin(.collapsed(2)); - try t.expect(hist.end(a, .collapsed(2)) == null); + try t.expect(hist.end(a, .collapsed(2), true) == null); var text: std.ArrayList(u8) = .empty; defer text.deinit(a); @@ -742,3 +744,20 @@ test "a commit says whether it started a step or joined one, and what it added" _ = hist.applyRedo(a, &text).?; try t.expectEqual(first.step, hist.lastApplied().?.step); } + +test "an edit another hand may not join starts a step of its own" { + const a = t.allocator; + var hist: History = .{}; + defer hist.deinit(a); + + hist.begin(.collapsed(0)); + hist.note(a, 0, "", "a"); + const first = hist.end(a, .collapsed(1), true).?; + hist.begin(.collapsed(1)); + hist.note(a, 1, "", "b"); + // Typing on, which VSCode's rule would join, but the step on top is another hand's. + const second = hist.end(a, .collapsed(2), false).?; + try t.expect(!second.joined); + try t.expect(second.step != first.step); + try t.expectEqual(@as(usize, 2), hist.undo_stack.items.len); +}