\{output.html}"
+ let model_view_start = converter.model_position_to_view_position(
+ Position(model_line_number, 1),
+ ).line_number
+ margin_html <+
+ ""
+ if model_view_start == view_line_number {
+ margin_html.write_string(model_line_number.to_string())
+ }
+ margin_html.write_string("
")
+ max_content_width = max_content_width.max(
+ output.character_mapping.column_horizontal_offset(
+ data.max_column,
+ space_width=configuration.font_info.space_width,
+ ),
+ )
+ }
+ {
+ content_html: content_html.to_string(),
+ margin_html: margin_html.to_string(),
+ max_content_width,
+ model_line_numbers,
+ }
+}
+
+///|
+/// Renders a source-model line range with this editor's current wrapping,
+/// font, whitespace, control-character, tab, and scrollbar configuration.
+/// The returned roots are ready to use as a ViewZone content/margin pair.
+/// Extra decorations stay in model UTF-16 coordinates and become native
+/// per-view-line `LineDecoration`s after wrapping.
+pub fn CodeEditorWidget::render_lines(
+ self : CodeEditorWidget,
+ source_model : @model.TextModel,
+ line_range : @base_common.LineRange,
+ decorations? : Array[CodeEditorRenderLinesDecoration] = [],
+ line_class_name? : String = "",
+ margin_line_class_name? : String = "",
+) -> CodeEditorRenderedLines? {
+ if self.disposed {
+ return None
+ }
+ let start_line_number = line_range.start_line_number.max(1)
+ let end_line_number_exclusive = line_range.end_line_number_exclusive.min(
+ source_model.get_line_count() + 1,
+ )
+ if start_line_number >= end_line_number_exclusive {
+ return None
+ }
+ stabilize_render_lines_tokens(
+ source_model, start_line_number, end_line_number_exclusive,
+ )
+ let configuration = self.configuration.snapshot()
+ let detached_view_model = @view_model.ViewModel::with_options(
+ source_model,
+ configuration.view_model_options(0),
+ )
+ defer detached_view_model.dispose()
+ let converter = detached_view_model.coordinates_converter()
+ let view_start = converter.model_position_to_view_position(
+ Position(start_line_number, 1),
+ ).line_number
+ let final_model_line = end_line_number_exclusive - 1
+ let view_end = converter.model_position_to_view_position(
+ Position(
+ final_model_line,
+ source_model.get_line_max_column(final_model_line),
+ ),
+ ).line_number
+ let line_height = configuration.font_info.line_height
+ let model_line_view_counts = Array::make(
+ end_line_number_exclusive - start_line_number,
+ 0,
+ )
+ let rendered_window = render_detached_view_line_window(
+ detached_view_model,
+ configuration,
+ view_start,
+ view_start,
+ view_end + 1,
+ decorations,
+ line_class_name,
+ margin_line_class_name,
+ )
+ for model_line_number in rendered_window.model_line_numbers {
+ model_line_view_counts[model_line_number - start_line_number] += 1
+ }
+ let dom_node = @rdom.document().create_element("div")
+ dom_node.set_attribute("class", "view-lines")
+ dom_node.set_inner_html(rendered_window.content_html)
+ let margin_dom_node = @rdom.document().create_element("div")
+ margin_dom_node.set_inner_html(rendered_window.margin_html)
+ let bare_font_info = configuration.font_info.bare()
+ @browser_config.apply_font_info(dom_node, bare_font_info)
+ @browser_config.apply_font_info(margin_dom_node, bare_font_info)
+ let view_line_count = view_end - view_start + 1
+ Some({
+ dom_node,
+ margin_dom_node,
+ height_in_px: view_line_count.to_double() * line_height.to_double(),
+ min_width_in_px: (rendered_window.max_content_width + 16.0).min(1000000.0),
+ view_line_count,
+ model_line_view_counts,
+ })
+}
+
+///|
+pub extend CodeEditorRenderLinesDecoration with Debug::{to_repr}
+
+///|
+pub extend CodeEditorRenderLinesDecoration with Eq::{equal, not_equal}
diff --git a/editor/internal/viewer/code_editor_widget/native_render_lines_wbtest.mbt b/editor/internal/viewer/code_editor_widget/native_render_lines_wbtest.mbt
new file mode 100644
index 000000000..9251fd832
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/native_render_lines_wbtest.mbt
@@ -0,0 +1,151 @@
+///|
+extern "js" fn install_native_render_lines_test_dom() -> Unit =
+ #| () => {
+ #| globalThis.__nativeRenderLinesOriginalDocument = globalThis.document;
+ #| class TestElement {
+ #| constructor(tag) {
+ #| this.tagName = String(tag).toUpperCase();
+ #| this.attributes = new Map();
+ #| this._innerHTML = "";
+ #| this.style = {
+ #| setProperty(name, value) { this[name] = String(value); },
+ #| };
+ #| }
+ #| setAttribute(name, value) {
+ #| this.attributes.set(String(name), String(value));
+ #| }
+ #| getAttribute(name) {
+ #| return this.attributes.has(String(name))
+ #| ? this.attributes.get(String(name))
+ #| : null;
+ #| }
+ #| removeAttribute(name) { this.attributes.delete(String(name)); }
+ #| set innerHTML(value) { this._innerHTML = String(value); }
+ #| get innerHTML() { return this._innerHTML; }
+ #| }
+ #| globalThis.document = {
+ #| createElement(tag) { return new TestElement(tag); },
+ #| };
+ #| }
+
+///|
+extern "js" fn restore_native_render_lines_test_dom() -> Unit =
+ #| () => {
+ #| globalThis.document = globalThis.__nativeRenderLinesOriginalDocument;
+ #| delete globalThis.__nativeRenderLinesOriginalDocument;
+ #| }
+
+///|
+extern "js" fn native_render_lines_test_html(element : @rdom.Element) -> String = "(element) => element.innerHTML"
+
+///|
+fn with_native_render_lines_test_dom(body : () -> Unit raise) -> Unit raise {
+ install_native_render_lines_test_dom()
+ defer restore_native_render_lines_test_dom()
+ body()
+}
+
+///|
+test "native render_lines keeps tokenizer spans and UTF-16 decorations" {
+ let viewer = CodeEditorWidget::CodeEditorWidget(
+ options=CodeEditorOptions(font_size=12, line_height=18),
+ )
+ defer viewer.dispose()
+ let source = test_model("let old = \"😀\"", language_id="plaintext")
+ defer source.dispose()
+ viewer.set_model(Some(source))
+ viewer.test_set_viewport(width=800.0, height=180.0)
+ viewer.test_view_model().visible_lines_stabilized()
+ with_native_render_lines_test_dom(() => {
+ let rendered = viewer
+ .render_lines(
+ source,
+ LineRange(1, 2),
+ decorations=[
+ {
+ range: Range(1, 5, 1, 8),
+ class_name: "diff-editor-inline-char-delete",
+ },
+ ],
+ line_class_name="diff-editor-inline-deleted-line",
+ )
+ .unwrap()
+ let html = native_render_lines_test_html(rendered.dom_node())
+ assert_true(html.contains(""))
+ assert_true(
+ html.contains(
+ "old",
+ ),
+ )
+ assert_true(html.contains("😀"))
+ assert_eq(rendered.view_line_count(), 1)
+ assert_true(rendered.model_line_view_counts() == [1])
+ })
+}
+
+///|
+test "native render_lines uses editor wrapping and exact model-line heights" {
+ with_native_render_lines_test_dom(() => {
+ let source = test_model("abcdefghij")
+ defer source.dispose()
+ let options = CodeEditorOptions(
+ font_size=12,
+ line_height=18,
+ soft_wrap=true,
+ )
+ with_test_viewer(
+ "host",
+ viewer => {
+ viewer.test_set_soft_wrap_column(6)
+ let rendered = viewer.render_lines(source, LineRange(1, 2)).unwrap()
+ assert_eq(rendered.view_line_count(), 2)
+ assert_eq(rendered.height_in_px(), 36.0)
+ assert_true(rendered.model_line_view_counts() == [2])
+ let html = native_render_lines_test_html(rendered.dom_node())
+ assert_true(html.contains("top:0px"))
+ assert_true(html.contains("top:18px"))
+ },
+ options~,
+ )
+ })
+}
+
+///|
+test "native render_lines preserves tab CJK emoji and RTL source" {
+ with_native_render_lines_test_dom(() => {
+ let source = test_model("\t中😀 אבג")
+ defer source.dispose()
+ let options = CodeEditorOptions(font_size=12, line_height=18, tab_size=4)
+ with_test_viewer(
+ "host",
+ viewer => {
+ let rendered = viewer.render_lines(source, LineRange(1, 2)).unwrap()
+ let html = native_render_lines_test_html(rendered.dom_node())
+ assert_false(html.contains("\t"))
+ assert_true(html.contains("中"))
+ assert_true(html.contains("😀"))
+ assert_true(html.contains("אבג"))
+ assert_true(html.contains(" dir=\""))
+ assert_true(rendered.min_width_in_px() > 16.0)
+ },
+ options~,
+ )
+ })
+}
+
+///|
+test "native render_lines does not truncate a very long source line" {
+ with_native_render_lines_test_dom(() => {
+ let source_text = "x".repeat(2600) + "END"
+ let source = test_model(source_text)
+ defer source.dispose()
+ with_test_viewer("host", viewer => {
+ let rendered = viewer.render_lines(source, LineRange(1, 2)).unwrap()
+ let html = native_render_lines_test_html(rendered.dom_node())
+ assert_true(html.contains("END"))
+ assert_true(html.length() > 2600)
+ assert_true(rendered.min_width_in_px() > 1000.0)
+ assert_eq(rendered.view_line_count(), 1)
+ })
+ })
+}
diff --git a/editor/viewer/overlay_widgets_host.mbt b/editor/internal/viewer/code_editor_widget/overlay_widgets_host.mbt
similarity index 86%
rename from editor/viewer/overlay_widgets_host.mbt
rename to editor/internal/viewer/code_editor_widget/overlay_widgets_host.mbt
index efa5d2792..63b82d06d 100644
--- a/editor/viewer/overlay_widgets_host.mbt
+++ b/editor/internal/viewer/code_editor_widget/overlay_widgets_host.mbt
@@ -1,6 +1,6 @@
// The viewer facade's public overlay-widget API delegates to
// `internal/viewer/browser/view/overlay_widgets.mbt`. It lives here because every
-// `Viewer::` method must be defined in `Viewer`'s package.
+// `CodeEditorWidget::` method must be defined in `CodeEditorWidget`'s package.
///|
/// Constructs an unmanaged null-position overlay widget. The caller retains
@@ -20,8 +20,8 @@ pub fn overlay_widget(
/// re-add semantics. The registration outlives any per-model view: the id →
/// handle map is Monaco's `_overlayWidgets`, re-added to every new view
/// (`cew:1901-1905`).
-pub fn Viewer::add_overlay_widget(
- self : Viewer,
+pub fn CodeEditorWidget::add_overlay_widget(
+ self : CodeEditorWidget,
widget : @editor_browser.OverlayWidget,
) -> Unit {
let widget_id = widget.get_id()
@@ -37,8 +37,8 @@ pub fn Viewer::add_overlay_widget(
/// `ICodeEditor.removeOverlayWidget`. A stale handle therefore removes the
/// current registration with the same id; unknown ids and a missing view are
/// no-ops. The caller-owned DOM node is only unmounted.
-pub fn Viewer::remove_overlay_widget(
- self : Viewer,
+pub fn CodeEditorWidget::remove_overlay_widget(
+ self : CodeEditorWidget,
widget : @editor_browser.OverlayWidget,
) -> Unit {
let widget_id = widget.get_id()
@@ -53,8 +53,8 @@ pub fn Viewer::remove_overlay_widget(
/// Removes a registration only while the root still owns this exact handle.
/// Contribution teardown uses this narrower operation after callbacks that may
/// synchronously install a replacement under the same Monaco widget id.
-fn Viewer::remove_overlay_widget_if_current(
- self : Viewer,
+fn CodeEditorWidget::remove_overlay_widget_if_current(
+ self : CodeEditorWidget,
widget : @editor_browser.OverlayWidget,
) -> Bool {
let widget_id = widget.get_id()
@@ -73,8 +73,8 @@ fn Viewer::remove_overlay_widget_if_current(
///|
/// Internal id lookup for contribution objects that retain their Monaco id
/// rather than a second public handle.
-fn Viewer::overlay_widget_for_id(
- self : Viewer,
+fn CodeEditorWidget::overlay_widget_for_id(
+ self : CodeEditorWidget,
widget_id : String,
) -> @editor_browser.OverlayWidget? {
self.overlay_widgets.get(widget_id)
diff --git a/editor/internal/viewer/code_editor_widget/overview_ruler.mbt b/editor/internal/viewer/code_editor_widget/overview_ruler.mbt
new file mode 100644
index 000000000..d6bf38197
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/overview_ruler.mbt
@@ -0,0 +1,615 @@
+// A generic, kernel-owned overview-ruler primitive. This is a deliberately
+// small port of VS Code's OverviewRuler/OverviewZoneManager geometry rather
+// than a diff-specific renderer. The current ViewModel does not yet expose
+// getOverviewRulerDecorations, so callers submit immutable entries explicitly.
+
+///|
+pub(all) enum CodeEditorOverviewRulerLane {
+ Left
+ Center
+ Right
+ Full
+} derive(Eq, Debug)
+
+///|
+pub(all) struct CodeEditorOverviewRulerEntry {
+ id : Int
+ range : @base_common.LineRange
+ color : String
+ lane : CodeEditorOverviewRulerLane
+} derive(Eq, Debug)
+
+///|
+priv struct CodeEditorOverviewRulerOffsetEntry {
+ id : Int
+ start_offset : Double
+ end_offset : Double
+ color : String
+ lane : CodeEditorOverviewRulerLane
+}
+
+///|
+struct CodeEditorOverviewRulerBand {
+ id : Int
+ from : Int
+ to : Int
+ color : String
+ lane : CodeEditorOverviewRulerLane
+} derive(Eq, Debug)
+
+///|
+fn code_editor_overview_ruler_lane_value(
+ lane : CodeEditorOverviewRulerLane,
+) -> Int {
+ match lane {
+ Left => 1
+ Center => 2
+ Right => 4
+ Full => 7
+ }
+}
+
+///|
+fn code_editor_overview_ruler_lane_geometry(
+ lane : CodeEditorOverviewRulerLane,
+ width : Int,
+) -> (Int, Int) {
+ let width = width.max(0)
+ let left_width = width / 3
+ let right_width = width / 3
+ let center_width = width - left_width - right_width
+ match lane {
+ Left => (0, left_width)
+ Center => (left_width, center_width)
+ Right => (left_width + center_width, right_width)
+ Full => (0, width)
+ }
+}
+
+///|
+fn resolve_code_editor_overview_ruler_bands(
+ entries : Array[CodeEditorOverviewRulerOffsetEntry],
+ dom_height : Double,
+ outer_height : Double,
+ pixel_ratio : Double,
+ minimum_marker_height : Double,
+) -> Array[CodeEditorOverviewRulerBand] {
+ let total_height = (dom_height.max(0.0) * pixel_ratio.max(1.0))
+ .floor()
+ .to_int()
+ let outer_height = outer_height.max(0.0)
+ if total_height <= 0 || outer_height <= 0.0 {
+ return []
+ }
+ let height_ratio = total_height.to_double() / outer_height
+ let minimum_height = (minimum_marker_height.max(1.0) * pixel_ratio.max(1.0))
+ .floor()
+ .to_int()
+ .max(1)
+ .min(total_height)
+ let bands : Array[CodeEditorOverviewRulerBand] = []
+ for entry in entries {
+ if entry.start_offset < 0.0 || entry.end_offset < entry.start_offset {
+ continue
+ }
+ let scaled_start = (height_ratio * entry.start_offset)
+ .floor()
+ .to_int()
+ .clamp(min=0, max=total_height)
+ let scaled_end = (height_ratio * entry.end_offset)
+ .floor()
+ .to_int()
+ .clamp(min=scaled_start, max=total_height)
+ let marker_height = (scaled_end - scaled_start)
+ .max(minimum_height)
+ .min(total_height)
+ let center = (scaled_start + scaled_end) / 2
+ let from = (center - marker_height / 2).clamp(
+ min=0,
+ max=total_height - marker_height,
+ )
+ bands.push({
+ id: entry.id,
+ from,
+ to: from + marker_height,
+ color: entry.color,
+ lane: entry.lane,
+ })
+ }
+ bands
+}
+
+///|
+fn compare_code_editor_overview_ruler_bands(
+ left : CodeEditorOverviewRulerBand,
+ right : CodeEditorOverviewRulerBand,
+) -> Int {
+ let lane = code_editor_overview_ruler_lane_value(left.lane) -
+ code_editor_overview_ruler_lane_value(right.lane)
+ if lane != 0 {
+ return lane
+ }
+ if left.color != right.color {
+ return if left.color < right.color { -1 } else { 1 }
+ }
+ if left.from != right.from {
+ return left.from - right.from
+ }
+ if left.to != right.to {
+ return left.to - right.to
+ }
+ left.id - right.id
+}
+
+///|
+fn merge_code_editor_overview_ruler_bands(
+ input : Array[CodeEditorOverviewRulerBand],
+) -> Array[CodeEditorOverviewRulerBand] {
+ let bands = input.copy()
+ bands.sort_by(compare_code_editor_overview_ruler_bands)
+ let merged : Array[CodeEditorOverviewRulerBand] = []
+ for band in bands {
+ if merged.is_empty() {
+ merged.push(band)
+ continue
+ }
+ let previous = merged[merged.length() - 1]
+ if previous.lane == band.lane &&
+ previous.color == band.color &&
+ previous.to >= band.from {
+ merged[merged.length() - 1] = {
+ id: previous.id,
+ from: previous.from,
+ to: previous.to.max(band.to),
+ color: previous.color,
+ lane: previous.lane,
+ }
+ } else {
+ merged.push(band)
+ }
+ }
+ merged
+}
+
+///|
+fn closest_code_editor_overview_ruler_entry(
+ bands : Array[CodeEditorOverviewRulerBand],
+ offset : Int,
+) -> Int? {
+ let mut best_id : Int? = None
+ let mut best_distance = 2147483647
+ let mut best_height = 2147483647
+ for band in bands {
+ let distance = if offset < band.from {
+ band.from - offset
+ } else if offset > band.to {
+ offset - band.to
+ } else {
+ 0
+ }
+ let height = band.to - band.from
+ if distance < best_distance ||
+ (distance == best_distance && height < best_height) ||
+ (
+ distance == best_distance &&
+ height == best_height &&
+ best_id.map_or(true, id => band.id < id)
+ ) {
+ best_id = Some(band.id)
+ best_distance = distance
+ best_height = height
+ }
+ }
+ best_id
+}
+
+///|
+pub struct CodeEditorOverviewRuler {
+ editor : CodeEditorWidget
+ root : @rdom.Element
+ minimum_marker_height : Double
+ on_select : ((Int) -> Unit)?
+ listeners : Array[@base_common.Disposable]
+ mut resize_observer : @base_common.Disposable?
+ mut entries : Array[CodeEditorOverviewRulerEntry]
+ mut bands : Array[CodeEditorOverviewRulerBand]
+ mut generation : Int
+ mut disposed : Bool
+}
+
+///|
+fn CodeEditorOverviewRuler::attach_to_current_view(
+ self : CodeEditorOverviewRuler,
+) -> Unit {
+ @base_browser.remove_element(self.root)
+ match self.editor.get_dom_node() {
+ Some(root) => root.append_child(self.root.as_node())
+ None => ()
+ }
+}
+
+///|
+fn CodeEditorOverviewRuler::model_offset_entries(
+ self : CodeEditorOverviewRuler,
+) -> Array[CodeEditorOverviewRulerOffsetEntry] {
+ guard self.editor.get_model() is Some(model) else { return [] }
+ let line_count = model.get_line_count()
+ let result : Array[CodeEditorOverviewRulerOffsetEntry] = []
+ for entry in self.entries {
+ if entry.range.is_empty() || line_count <= 0 {
+ continue
+ }
+ let start_line = entry.range.start_line_number.clamp(min=1, max=line_count)
+ let end_line = (entry.range.end_line_number_exclusive - 1).clamp(
+ min=start_line,
+ max=line_count,
+ )
+ let start_offset = self.editor.get_top_for_line_number(
+ start_line,
+ include_view_zones=false,
+ )
+ let end_offset = self.editor.get_vertical_offset_after_position(
+ end_line,
+ model.get_line_max_column(end_line),
+ include_view_zones=false,
+ )
+ result.push({
+ id: entry.id,
+ start_offset,
+ end_offset,
+ color: entry.color,
+ lane: entry.lane,
+ })
+ }
+ result
+}
+
+///|
+fn CodeEditorOverviewRuler::render(self : CodeEditorOverviewRuler) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.root.set_attribute(
+ "data-overview-ruler-generation",
+ self.generation.to_string(),
+ )
+ self.root.set_attribute(
+ "data-overview-ruler-entry-count",
+ self.entries.length().to_string(),
+ )
+ let dom_height = self.root.get_bounding_client_rect().get_height().max(0.0)
+ let outer_height = self.editor.get_scroll_height().max(dom_height)
+ let scroll_top = self.editor.get_scroll_top().max(0.0)
+ self.root.set_attribute(
+ "data-overview-ruler-scroll-top",
+ scroll_top.to_string(),
+ )
+ self.root.set_attribute(
+ "data-overview-ruler-scroll-height",
+ outer_height.to_string(),
+ )
+ let pixel_ratio = @base_browser.device_pixel_ratio().max(1.0)
+ prepare_code_editor_overview_ruler_canvas(self.root, dom_height, pixel_ratio)
+ self.bands = resolve_code_editor_overview_ruler_bands(
+ self.model_offset_entries(),
+ dom_height,
+ outer_height,
+ pixel_ratio,
+ self.minimum_marker_height,
+ )
+ let render_bands = merge_code_editor_overview_ruler_bands(self.bands)
+ let resolved_colors : Map[String, String] = Map([])
+ for band in render_bands {
+ let color = match resolved_colors.get(band.color) {
+ Some(color) => color
+ None => {
+ let color = resolve_code_editor_overview_ruler_color(
+ self.root,
+ band.color,
+ )
+ resolved_colors[band.color] = color
+ color
+ }
+ }
+ let (left, width) = code_editor_overview_ruler_lane_geometry(
+ band.lane,
+ code_editor_overview_ruler_canvas_width(self.root),
+ )
+ fill_code_editor_overview_ruler_canvas_band(
+ self.root,
+ left,
+ width,
+ band.from,
+ band.to,
+ color,
+ )
+ }
+ self.root.set_attribute(
+ "data-overview-ruler-band-count",
+ render_bands.length().to_string(),
+ )
+}
+
+///|
+fn CodeEditorOverviewRuler::handle_pointer_down(
+ self : CodeEditorOverviewRuler,
+ event : @rdom.Event,
+) -> Unit {
+ guard !self.disposed &&
+ self.on_select is Some(on_select) &&
+ event.to_mouse_event() is Some(mouse) &&
+ mouse.get_button() == 0 else {
+ return
+ }
+ let rect = self.root.get_bounding_client_rect()
+ let pixel_ratio = @base_browser.device_pixel_ratio().max(1.0)
+ let offset = ((mouse.get_client_y().to_double() - rect.get_top()) *
+ pixel_ratio)
+ .round()
+ .to_int()
+ guard closest_code_editor_overview_ruler_entry(self.bands, offset) is Some(id) else {
+ return
+ }
+ event.prevent_default()
+ event.stop_propagation()
+ on_select(id)
+}
+
+///|
+fn CodeEditorOverviewRuler::clear_for_model_transition(
+ self : CodeEditorOverviewRuler,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.entries = []
+ self.attach_to_current_view()
+ self.render()
+}
+
+///|
+fn CodeEditorOverviewRuler::clear_for_content_change(
+ self : CodeEditorOverviewRuler,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.entries = []
+ self.render()
+}
+
+///|
+fn initialize_code_editor_overview_ruler_dom(root : @rdom.Element) -> Unit {
+ root.set_attribute(
+ "class", "decorationsOverviewRuler moonbit-code-overview-ruler",
+ )
+ root.set_attribute("data-view-part", "overview-ruler")
+ root.set_attribute("aria-hidden", "true")
+ root.set_attribute("data-overview-ruler-generation", "0")
+ root.set_attribute("data-overview-ruler-entry-count", "0")
+ root.set_attribute("data-overview-ruler-band-count", "0")
+}
+
+///|
+fn code_editor_overview_ruler_accepts_generation(
+ current_generation : Int,
+ incoming_generation : Int,
+) -> Bool {
+ incoming_generation >= current_generation
+}
+
+///|
+pub fn CodeEditorWidget::create_overview_ruler(
+ self : CodeEditorWidget,
+ minimum_marker_height? : Double = 4.0,
+ on_select? : (Int) -> Unit,
+) -> CodeEditorOverviewRuler {
+ let root = @rdom.document().create_element("canvas")
+ initialize_code_editor_overview_ruler_dom(root)
+ let ruler : CodeEditorOverviewRuler = {
+ editor: self,
+ root,
+ minimum_marker_height: minimum_marker_height.max(1.0),
+ on_select,
+ listeners: [],
+ resize_observer: None,
+ entries: [],
+ bands: [],
+ generation: 0,
+ disposed: false,
+ }
+ let pointer_down = event => ruler.handle_pointer_down(event)
+ root.add_event_listener("pointerdown", pointer_down)
+ ruler.listeners.push(
+ @base_common.Disposable::from(() => {
+ root.remove_event_listener("pointerdown", pointer_down)
+ }),
+ )
+ ruler.listeners.push(
+ self.on_did_change_model(_ => ruler.clear_for_model_transition()),
+ )
+ ruler.listeners.push(
+ self.on_did_change_model_content(_ => ruler.clear_for_content_change()),
+ )
+ ruler.listeners.push(self.on_did_scroll_change(_ => ruler.render()))
+ ruler.listeners.push(self.on_did_change_view_zones(() => ruler.render()))
+ ruler.listeners.push(self.on_did_change_hidden_areas(() => ruler.render()))
+ ruler.resize_observer = @base_browser.observe_element_resize(
+ self.get_container_dom_node(),
+ () => ruler.render(),
+ )
+ if self.disposed {
+ ruler.dispose()
+ } else {
+ self.overview_rulers.push(ruler)
+ ruler.attach_to_current_view()
+ ruler.render()
+ }
+ ruler
+}
+
+///|
+pub fn CodeEditorOverviewRuler::set_entries(
+ self : CodeEditorOverviewRuler,
+ generation : Int,
+ entries : Array[CodeEditorOverviewRulerEntry],
+) -> Unit {
+ if self.disposed ||
+ !code_editor_overview_ruler_accepts_generation(self.generation, generation) {
+ return
+ }
+ self.generation = generation
+ self.entries = entries.copy()
+ self.render()
+}
+
+///|
+pub fn CodeEditorOverviewRuler::clear(
+ self : CodeEditorOverviewRuler,
+ generation : Int,
+) -> Unit {
+ self.set_entries(generation, [])
+}
+
+///|
+pub fn CodeEditorOverviewRuler::get_dom_node(
+ self : CodeEditorOverviewRuler,
+) -> @rdom.Element {
+ self.root
+}
+
+///|
+pub fn CodeEditorOverviewRuler::generation(
+ self : CodeEditorOverviewRuler,
+) -> Int {
+ self.generation
+}
+
+///|
+pub fn CodeEditorOverviewRuler::entry_count(
+ self : CodeEditorOverviewRuler,
+) -> Int {
+ self.entries.length()
+}
+
+///|
+pub fn CodeEditorOverviewRuler::is_disposed(
+ self : CodeEditorOverviewRuler,
+) -> Bool {
+ self.disposed
+}
+
+///|
+fn CodeEditorWidget::remove_managed_overview_ruler(
+ self : CodeEditorWidget,
+ ruler : CodeEditorOverviewRuler,
+) -> Unit {
+ for index, current in self.overview_rulers {
+ if physical_equal(current, ruler) {
+ self.overview_rulers.remove(index) |> ignore
+ return
+ }
+ }
+}
+
+///|
+fn CodeEditorWidget::refresh_managed_overview_rulers(
+ self : CodeEditorWidget,
+) -> Unit {
+ for ruler in self.overview_rulers {
+ ruler.render()
+ }
+}
+
+///|
+fn CodeEditorWidget::dispose_managed_overview_rulers(
+ self : CodeEditorWidget,
+) -> Unit {
+ for ruler in self.overview_rulers.copy() {
+ ruler.dispose()
+ }
+ self.overview_rulers.clear()
+}
+
+///|
+pub fn CodeEditorOverviewRuler::dispose(self : CodeEditorOverviewRuler) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ match self.resize_observer {
+ Some(observer) => observer.dispose()
+ None => ()
+ }
+ self.resize_observer = None
+ for listener in self.listeners {
+ listener.dispose()
+ }
+ self.listeners.clear()
+ self.entries = []
+ self.bands = []
+ clear_code_editor_overview_ruler_canvas(self.root)
+ @base_browser.remove_element(self.root)
+ if !self.editor.disposed {
+ self.editor.remove_managed_overview_ruler(self)
+ }
+}
+
+///|
+extern "js" fn prepare_code_editor_overview_ruler_canvas(
+ canvas : @rdom.Element,
+ css_height : Double,
+ pixel_ratio : Double,
+) -> Unit =
+ #| (canvas, cssHeight, pixelRatio) => {
+ #| const cssWidth = canvas.getBoundingClientRect().width;
+ #| const width = Math.max(0, Math.floor(cssWidth * pixelRatio));
+ #| const height = Math.max(0, Math.floor(cssHeight * pixelRatio));
+ #| if (canvas.width !== width) canvas.width = width;
+ #| if (canvas.height !== height) canvas.height = height;
+ #| canvas.getContext('2d')?.clearRect(0, 0, width, height);
+ #| }
+
+///|
+extern "js" fn code_editor_overview_ruler_canvas_width(
+ canvas : @rdom.Element,
+) -> Int = "(canvas) => canvas.width | 0"
+
+///|
+extern "js" fn resolve_code_editor_overview_ruler_color(
+ canvas : @rdom.Element,
+ expression : String,
+) -> String =
+ #| (canvas, expression) => {
+ #| canvas.style.setProperty('color', expression);
+ #| const resolved = getComputedStyle(canvas).color || expression;
+ #| canvas.style.removeProperty('color');
+ #| return resolved;
+ #| }
+
+///|
+extern "js" fn fill_code_editor_overview_ruler_canvas_band(
+ canvas : @rdom.Element,
+ left : Int,
+ width : Int,
+ from : Int,
+ to : Int,
+ color : String,
+) -> Unit =
+ #| (canvas, left, width, from, to, color) => {
+ #| const context = canvas.getContext('2d');
+ #| if (!context || width <= 0 || to <= from) return;
+ #| context.fillStyle = color;
+ #| context.fillRect(left, from, width, to - from);
+ #| }
+
+///|
+extern "js" fn clear_code_editor_overview_ruler_canvas(
+ canvas : @rdom.Element,
+) -> Unit =
+ #| (canvas) => {
+ #| const context = canvas.getContext?.('2d');
+ #| context?.clearRect(0, 0, canvas.width || 0, canvas.height || 0);
+ #| canvas.width = 0;
+ #| canvas.height = 0;
+ #| }
diff --git a/editor/internal/viewer/code_editor_widget/overview_ruler_wbtest.mbt b/editor/internal/viewer/code_editor_widget/overview_ruler_wbtest.mbt
new file mode 100644
index 000000000..f2e4082ca
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/overview_ruler_wbtest.mbt
@@ -0,0 +1,197 @@
+///|
+test "overview ruler scales ranges and enforces its minimum marker height" {
+ let bands = resolve_code_editor_overview_ruler_bands(
+ [
+ {
+ id: 7,
+ start_offset: 500.0,
+ end_offset: 501.0,
+ color: "red",
+ lane: Left,
+ },
+ ],
+ 100.0,
+ 1000.0,
+ 2.0,
+ 4.0,
+ )
+ assert_eq(bands, [{ id: 7, from: 96, to: 104, color: "red", lane: Left }])
+}
+
+///|
+test "overview ruler clamps marker bands to both canvas edges" {
+ let bands = resolve_code_editor_overview_ruler_bands(
+ [
+ { id: 1, start_offset: 0.0, end_offset: 0.0, color: "red", lane: Left },
+ {
+ id: 2,
+ start_offset: 1000.0,
+ end_offset: 1000.0,
+ color: "green",
+ lane: Right,
+ },
+ ],
+ 100.0,
+ 1000.0,
+ 1.0,
+ 4.0,
+ )
+ assert_eq(bands, [
+ { id: 1, from: 0, to: 4, color: "red", lane: Left },
+ { id: 2, from: 96, to: 100, color: "green", lane: Right },
+ ])
+}
+
+///|
+test "overview ruler merges only overlapping bands in the same color lane" {
+ let merged = merge_code_editor_overview_ruler_bands([
+ { id: 3, from: 2, to: 8, color: "red", lane: Left },
+ { id: 4, from: 7, to: 12, color: "red", lane: Left },
+ { id: 5, from: 7, to: 12, color: "green", lane: Left },
+ { id: 6, from: 7, to: 12, color: "red", lane: Right },
+ ])
+ assert_eq(merged, [
+ { id: 3, from: 2, to: 12, color: "red", lane: Left },
+ { id: 5, from: 7, to: 12, color: "green", lane: Left },
+ { id: 6, from: 7, to: 12, color: "red", lane: Right },
+ ])
+}
+
+///|
+test "overview ruler lane geometry is stable for uneven widths" {
+ assert_eq(code_editor_overview_ruler_lane_geometry(Left, 11), (0, 3))
+ assert_eq(code_editor_overview_ruler_lane_geometry(Center, 11), (3, 5))
+ assert_eq(code_editor_overview_ruler_lane_geometry(Right, 11), (8, 3))
+ assert_eq(code_editor_overview_ruler_lane_geometry(Full, 11), (0, 11))
+}
+
+///|
+test "overview ruler pointer selection chooses the nearest narrow band" {
+ let bands = [
+ { id: 4, from: 20, to: 40, color: "red", lane: Left },
+ { id: 2, from: 28, to: 32, color: "green", lane: Right },
+ { id: 9, from: 70, to: 80, color: "blue", lane: Full },
+ ]
+ assert_eq(closest_code_editor_overview_ruler_entry(bands, 30), Some(2))
+ assert_eq(closest_code_editor_overview_ruler_entry(bands, 60), Some(9))
+ assert_eq(closest_code_editor_overview_ruler_entry([], 60), None)
+}
+
+///|
+extern "js" fn overview_ruler_test_element() -> @rdom.Element =
+ #| () => ({
+ #| attributes: new Map(),
+ #| setAttribute(name, value) {
+ #| this.attributes.set(String(name), String(value));
+ #| },
+ #| getAttribute(name) {
+ #| return this.attributes.has(String(name))
+ #| ? this.attributes.get(String(name))
+ #| : null;
+ #| },
+ #| })
+
+///|
+test "overview ruler publishes a browser-testable inaccessible DOM contract" {
+ let root = overview_ruler_test_element()
+ initialize_code_editor_overview_ruler_dom(root)
+ assert_eq(
+ root.get_attribute("class"),
+ Some("decorationsOverviewRuler moonbit-code-overview-ruler"),
+ )
+ assert_eq(root.get_attribute("data-view-part"), Some("overview-ruler"))
+ assert_eq(root.get_attribute("aria-hidden"), Some("true"))
+ assert_eq(root.get_attribute("data-overview-ruler-generation"), Some("0"))
+ assert_eq(root.get_attribute("data-overview-ruler-entry-count"), Some("0"))
+ assert_eq(root.get_attribute("data-overview-ruler-band-count"), Some("0"))
+}
+
+///|
+test "overview ruler rejects stale generations and accepts refreshes" {
+ assert_false(code_editor_overview_ruler_accepts_generation(9, 8))
+ assert_true(code_editor_overview_ruler_accepts_generation(9, 9))
+ assert_true(code_editor_overview_ruler_accepts_generation(9, 10))
+}
+
+///|
+extern "js" fn overview_ruler_disposal_test_canvas() -> @rdom.Element =
+ #| () => ({
+ #| width: 12,
+ #| height: 80,
+ #| removed: false,
+ #| getContext() { return null; },
+ #| remove() { this.removed = true; },
+ #| })
+
+///|
+extern "js" fn overview_ruler_disposal_test_width(root : @rdom.Element) -> Int = "(root) => root.width"
+
+///|
+extern "js" fn overview_ruler_disposal_test_height(root : @rdom.Element) -> Int = "(root) => root.height"
+
+///|
+extern "js" fn overview_ruler_disposal_test_removed(
+ root : @rdom.Element,
+) -> Bool = "(root) => root.removed"
+
+///|
+fn overview_ruler_disposal_test_handle(
+ editor : CodeEditorWidget,
+ root : @rdom.Element,
+ listeners? : Array[@base_common.Disposable] = [],
+ resize_observer? : @base_common.Disposable,
+) -> CodeEditorOverviewRuler {
+ {
+ editor,
+ root,
+ minimum_marker_height: 4.0,
+ on_select: None,
+ listeners,
+ resize_observer,
+ entries: [{ id: 1, range: LineRange(1, 2), color: "red", lane: Full }],
+ bands: [{ id: 1, from: 0, to: 4, color: "red", lane: Full }],
+ generation: 3,
+ disposed: false,
+ }
+}
+
+///|
+test "overview ruler disposal releases each owned resource exactly once" {
+ let editor = CodeEditorWidget::CodeEditorWidget()
+ defer editor.dispose()
+ let root = overview_ruler_disposal_test_canvas()
+ let mut released = 0
+ let ruler = overview_ruler_disposal_test_handle(
+ editor,
+ root,
+ listeners=[
+ @base_common.Disposable::from(() => released += 1),
+ @base_common.Disposable::from(() => released += 1),
+ ],
+ resize_observer=@base_common.Disposable::from(() => released += 1),
+ )
+ editor.overview_rulers.push(ruler)
+ ruler.dispose()
+ ruler.dispose()
+ assert_true(ruler.is_disposed())
+ assert_eq(ruler.entry_count(), 0)
+ assert_eq(editor.overview_rulers.length(), 0)
+ assert_eq(released, 3)
+ assert_eq(overview_ruler_disposal_test_width(root), 0)
+ assert_eq(overview_ruler_disposal_test_height(root), 0)
+ assert_true(overview_ruler_disposal_test_removed(root))
+}
+
+///|
+test "code editor disposal owns and clears registered overview rulers" {
+ let editor = CodeEditorWidget::CodeEditorWidget()
+ let root = overview_ruler_disposal_test_canvas()
+ let ruler = overview_ruler_disposal_test_handle(editor, root)
+ editor.overview_rulers.push(ruler)
+ editor.dispose()
+ assert_true(ruler.is_disposed())
+ assert_eq(editor.overview_rulers.length(), 0)
+ assert_eq(overview_ruler_disposal_test_width(root), 0)
+ assert_eq(overview_ruler_disposal_test_height(root), 0)
+ assert_true(overview_ruler_disposal_test_removed(root))
+}
diff --git a/editor/internal/viewer/code_editor_widget/pkg.generated.mbti b/editor/internal/viewer/code_editor_widget/pkg.generated.mbti
new file mode 100644
index 000000000..c0666817d
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/pkg.generated.mbti
@@ -0,0 +1,365 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/editor/internal/viewer/code_editor_widget"
+
+import {
+ "moonbit-community/rabbita/dom",
+ "moonbitlang/core/debug",
+ "moonbitlang/editor/base/common",
+ "moonbitlang/editor/language",
+ "moonbitlang/editor/platform/log",
+ "moonbitlang/editor/viewer/common/agent_feedback_api",
+ "moonbitlang/editor/viewer/common/config",
+ "moonbitlang/editor/viewer/common/core",
+ "moonbitlang/editor/viewer/common/editor_api",
+ "moonbitlang/editor/viewer/common/languages",
+ "moonbitlang/editor/viewer/common/markers",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
+ "moonbitlang/editor/viewer/common/quick_diff_api",
+ "moonbitlang/editor/viewer/common/view_model",
+}
+
+// Values
+pub fn code_editor_references_preview_factory(CodeEditorServices, CodeEditorOptions) -> @moonbitlang/editor/internal/viewer/contrib/references/browser.ReferencesPeekPreviewFactory
+
+pub fn overlay_widget(String, @dom.Element) -> @moonbitlang/editor/viewer/browser.OverlayWidget
+
+pub fn view_zone(Int, @dom.Element, after_column? : Int, after_column_affinity? : @model.PositionAffinity, show_in_hidden_areas? : Bool, ignore_hidden_area_source? : String, ordinal? : Int, suppress_mouse_down? : Bool, height_in_lines? : Double, height_in_px? : Double, min_width_in_px? : Double, margin_dom_node? : @dom.Element, on_dom_node_top? : (Double) -> Unit raise, on_dom_node_layout? : (Bool, Double) -> Unit raise, on_computed_height? : (Double) -> Unit raise) -> @moonbitlang/editor/viewer/browser.ViewZone
+
+// Errors
+
+// Types and methods
+pub struct CodeEditorContributionDescriptors {
+ // private fields
+}
+pub fn CodeEditorContributionDescriptors::diff_pane() -> Self
+pub fn CodeEditorContributionDescriptors::peek_preview() -> Self
+pub fn CodeEditorContributionDescriptors::standalone() -> Self
+
+pub(all) struct CodeEditorForeignZoneHeight {
+ after_line_number : Int
+ height_in_px : Double
+} derive(Eq, @debug.Debug)
+pub fn CodeEditorForeignZoneHeight::equal(Self, Self) -> Bool
+pub fn CodeEditorForeignZoneHeight::not_equal(Self, Self) -> Bool
+pub fn CodeEditorForeignZoneHeight::to_repr(Self) -> @debug.Repr
+
+pub struct CodeEditorGeometrySnapshot {
+ model_version : Int
+ line_height : Double
+ model_line_heights : Array[CodeEditorModelLineHeight]
+ foreign_zone_heights : Array[CodeEditorForeignZoneHeight]
+ line_max_columns : Array[Int]
+ scroll_content : CodeEditorScrollContentFacts
+}
+pub fn CodeEditorGeometrySnapshot::foreign_zone_heights(Self) -> Array[CodeEditorForeignZoneHeight]
+pub fn CodeEditorGeometrySnapshot::line_height(Self) -> Double
+pub fn CodeEditorGeometrySnapshot::line_max_columns(Self) -> Array[Int]
+pub fn CodeEditorGeometrySnapshot::model_line_heights(Self) -> Array[CodeEditorModelLineHeight]
+pub fn CodeEditorGeometrySnapshot::model_version(Self) -> Int
+pub fn CodeEditorGeometrySnapshot::scroll_content(Self) -> CodeEditorScrollContentFacts
+
+pub(all) struct CodeEditorModelLineHeight {
+ line_number : Int
+ height_in_px : Double
+} derive(Eq, @debug.Debug)
+pub fn CodeEditorModelLineHeight::equal(Self, Self) -> Bool
+pub fn CodeEditorModelLineHeight::not_equal(Self, Self) -> Bool
+pub fn CodeEditorModelLineHeight::to_repr(Self) -> @debug.Repr
+
+type CodeEditorOptions derive(Eq, @debug.Debug)
+pub fn CodeEditorOptions::CodeEditorOptions(soft_wrap? : Bool, tab_size? : Int, wrapping_indent? : @editor_api.WrappingIndent, render_whitespace? : @editor_api.RenderWhitespace, render_control_characters? : Bool, render_line_highlight? : @editor_api.RenderLineHighlight, render_line_highlight_only_when_focus? : Bool, render_validation_decorations? : @editor_api.RenderValidationDecorations, theme? : String, placeholder? : String, line_decorations_width? : Int, line_numbers_min_chars? : Int, folding? : Bool, show_folding_controls? : @editor_api.ShowFoldingControls, folding_highlight? : Bool, folding_maximum_regions? : Int, unfold_on_click_after_end_of_line? : Bool, smooth_scrolling? : Bool, show_unused? : Bool, show_deprecated? : Bool, font_family? : String, font_weight? : String, font_size? : Double, line_height? : Double, letter_spacing? : Double) -> Self
+pub fn CodeEditorOptions::default() -> Self
+pub fn CodeEditorOptions::equal(Self, Self) -> Bool
+pub fn CodeEditorOptions::line_height(Self) -> Double
+pub fn CodeEditorOptions::not_equal(Self, Self) -> Bool
+pub fn CodeEditorOptions::soft_wrap(Self) -> Bool
+pub fn CodeEditorOptions::with_font_family(Self, String) -> Self
+pub fn CodeEditorOptions::with_font_size(Self, Double) -> Self
+pub fn CodeEditorOptions::with_line_decorations_width(Self, Int) -> Self
+pub fn CodeEditorOptions::with_line_height(Self, Double) -> Self
+pub fn CodeEditorOptions::with_line_numbers_min_chars(Self, Int) -> Self
+pub fn CodeEditorOptions::with_placeholder(Self, String) -> Self
+pub fn CodeEditorOptions::with_render_control_characters(Self, Bool) -> Self
+pub fn CodeEditorOptions::with_render_line_highlight(Self, @editor_api.RenderLineHighlight) -> Self
+pub fn CodeEditorOptions::with_render_line_highlight_only_when_focus(Self, Bool) -> Self
+pub fn CodeEditorOptions::with_render_validation_decorations(Self, @editor_api.RenderValidationDecorations) -> Self
+pub fn CodeEditorOptions::with_render_whitespace(Self, @editor_api.RenderWhitespace) -> Self
+pub fn CodeEditorOptions::with_soft_wrap(Self, Bool) -> Self
+pub fn CodeEditorOptions::with_tab_size(Self, Int) -> Self
+pub fn CodeEditorOptions::with_theme(Self, String) -> Self
+
+pub struct CodeEditorOverviewRuler {
+ editor : CodeEditorWidget
+ root : @dom.Element
+ minimum_marker_height : Double
+ on_select : ((Int) -> Unit)?
+ listeners : Array[@common.Disposable]
+ mut resize_observer : @common.Disposable?
+ mut entries : Array[CodeEditorOverviewRulerEntry]
+ mut bands : Array[CodeEditorOverviewRulerBand]
+ mut generation : Int
+ mut disposed : Bool
+}
+pub fn CodeEditorOverviewRuler::clear(Self, Int) -> Unit
+pub fn CodeEditorOverviewRuler::dispose(Self) -> Unit
+pub fn CodeEditorOverviewRuler::entry_count(Self) -> Int
+pub fn CodeEditorOverviewRuler::generation(Self) -> Int
+pub fn CodeEditorOverviewRuler::get_dom_node(Self) -> @dom.Element
+pub fn CodeEditorOverviewRuler::is_disposed(Self) -> Bool
+pub fn CodeEditorOverviewRuler::set_entries(Self, Int, Array[CodeEditorOverviewRulerEntry]) -> Unit
+
+type CodeEditorOverviewRulerBand derive(Eq, @debug.Debug)
+
+pub(all) struct CodeEditorOverviewRulerEntry {
+ id : Int
+ range : @common.LineRange
+ color : String
+ lane : CodeEditorOverviewRulerLane
+} derive(Eq, @debug.Debug)
+
+pub(all) enum CodeEditorOverviewRulerLane {
+ Left
+ Center
+ Right
+ Full
+} derive(Eq, @debug.Debug)
+
+pub(all) struct CodeEditorRenderLinesDecoration {
+ range : @common.Range
+ class_name : String
+} derive(Eq, @debug.Debug)
+pub fn CodeEditorRenderLinesDecoration::equal(Self, Self) -> Bool
+pub fn CodeEditorRenderLinesDecoration::not_equal(Self, Self) -> Bool
+pub fn CodeEditorRenderLinesDecoration::to_repr(Self) -> @debug.Repr
+
+pub struct CodeEditorRenderedLines {
+ dom_node : @dom.Element
+ margin_dom_node : @dom.Element
+ height_in_px : Double
+ min_width_in_px : Double
+ view_line_count : Int
+ model_line_view_counts : Array[Int]
+}
+pub fn CodeEditorRenderedLines::dom_node(Self) -> @dom.Element
+pub fn CodeEditorRenderedLines::height_in_px(Self) -> Double
+pub fn CodeEditorRenderedLines::margin_dom_node(Self) -> @dom.Element
+pub fn CodeEditorRenderedLines::min_width_in_px(Self) -> Double
+pub fn CodeEditorRenderedLines::model_line_view_counts(Self) -> Array[Int]
+pub fn CodeEditorRenderedLines::view_line_count(Self) -> Int
+
+pub(all) struct CodeEditorScrollContentFacts {
+ scroll_top : Double
+ scroll_left : Double
+ viewport_width : Double
+ viewport_height : Double
+ content_width : Double
+ content_height : Double
+ scroll_width : Double
+ scroll_height : Double
+} derive(Eq, @debug.Debug)
+pub fn CodeEditorScrollContentFacts::equal(Self, Self) -> Bool
+pub fn CodeEditorScrollContentFacts::not_equal(Self, Self) -> Bool
+pub fn CodeEditorScrollContentFacts::to_repr(Self) -> @debug.Repr
+
+type CodeEditorServices
+pub fn CodeEditorServices::CodeEditorServices(languages? : @languages.LanguageHandle, markers? : @markers.MarkerDecorationsSource, agent_feedback? : @agent_feedback_api.AgentFeedbackHandle, location_opener? : @navigation_api.LocationOpenerHandle, text_model_resolver? : @navigation_api.TextModelResolverHandle, quick_diff? : @quick_diff_api.QuickDiffHandle, log_service? : @log.LogHandle) -> Self
+pub fn CodeEditorServices::agent_feedback(Self) -> @agent_feedback_api.AgentFeedbackHandle
+pub fn CodeEditorServices::dispose(Self) -> Unit
+pub fn CodeEditorServices::languages(Self) -> @languages.LanguageHandle
+pub fn CodeEditorServices::location_opener(Self) -> @navigation_api.LocationOpenerHandle?
+pub fn CodeEditorServices::log_service(Self) -> @log.LogHandle
+pub fn CodeEditorServices::marker_decorations(Self) -> @markers.MarkerDecorationsHandle
+pub fn CodeEditorServices::quick_diff(Self) -> @quick_diff_api.QuickDiffHandle
+pub fn CodeEditorServices::text_model_resolver(Self) -> @navigation_api.TextModelResolverHandle?
+
+type CodeEditorViewState derive(@debug.Debug)
+
+type CodeEditorWidget
+pub fn CodeEditorWidget::add_overlay_widget(Self, @moonbitlang/editor/viewer/browser.OverlayWidget) -> Unit
+pub fn CodeEditorWidget::after_next_render(Self, () -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::apply_managed_view_zones(Self, Array[String], Array[ManagedCodeEditorViewZoneSpec], Int) -> Array[String]
+pub fn CodeEditorWidget::capture_stable_viewport_state(Self) -> StableCodeEditorViewportState?
+pub fn CodeEditorWidget::change_view_zones(Self, (@moonbitlang/editor/viewer/browser.ViewZoneChangeAccessor) -> Unit raise) -> Unit
+pub fn CodeEditorWidget::create(@dom.Element, services? : CodeEditorServices, options? : CodeEditorOptions, contributions~ : CodeEditorContributionDescriptors) -> Self
+pub fn CodeEditorWidget::create_decorations_collection(Self, decorations? : Array[@model.ModelDeltaDecoration]) -> EditorDecorationsCollection
+pub fn CodeEditorWidget::create_overview_ruler(Self, minimum_marker_height? : Double, on_select? : (Int) -> Unit) -> CodeEditorOverviewRuler
+pub fn CodeEditorWidget::create_render_lines_context(Self, @model.TextModel, owner_generation? : Int) -> VirtualizedCodeEditorRenderLinesContext?
+pub fn CodeEditorWidget::delta_decorations(Self, Array[String], Array[@model.ModelDeltaDecoration]) -> Array[String]
+pub fn CodeEditorWidget::dispose(Self) -> Unit
+pub fn CodeEditorWidget::focus(Self) -> Unit
+pub fn CodeEditorWidget::fold_top_level(Self) -> Unit
+pub fn CodeEditorWidget::geometry_snapshot(Self, Array[String]) -> CodeEditorGeometrySnapshot?
+pub fn CodeEditorWidget::get_bottom_for_line_number(Self, Int) -> Double
+pub fn CodeEditorWidget::get_container_dom_node(Self) -> @dom.Element
+pub fn CodeEditorWidget::get_content_height(Self) -> Double
+pub fn CodeEditorWidget::get_content_width(Self) -> Double
+pub fn CodeEditorWidget::get_decorations_in_range(Self, @common.Range) -> Array[@model.ModelDecoration]
+pub fn CodeEditorWidget::get_dom_node(Self) -> @dom.Element?
+pub fn CodeEditorWidget::get_editor_type(Self) -> String
+pub fn CodeEditorWidget::get_id(Self) -> String
+pub fn CodeEditorWidget::get_layout_info(Self) -> @config.EditorLayoutInfo
+pub fn CodeEditorWidget::get_line_decorations(Self, Int) -> Array[@model.ModelDecoration]
+pub fn CodeEditorWidget::get_line_height_for_position(Self, @common.Position) -> Double
+pub fn CodeEditorWidget::get_model(Self) -> @model.TextModel?
+pub fn CodeEditorWidget::get_offset_for_column(Self, Int, Int) -> Double
+pub fn CodeEditorWidget::get_options(Self) -> CodeEditorOptions
+pub fn CodeEditorWidget::get_position(Self) -> @common.Position?
+pub fn CodeEditorWidget::get_scroll_height(Self) -> Double
+pub fn CodeEditorWidget::get_scroll_left(Self) -> Double
+pub fn CodeEditorWidget::get_scroll_top(Self) -> Double
+pub fn CodeEditorWidget::get_scroll_width(Self) -> Double
+pub fn CodeEditorWidget::get_scrolled_visible_position(Self, @common.Position) -> @editor_api.ScrolledVisiblePosition?
+pub fn CodeEditorWidget::get_selection(Self) -> @core.Selection?
+pub fn CodeEditorWidget::get_selections(Self) -> Array[@core.Selection]?
+pub fn CodeEditorWidget::get_top_for_line_number(Self, Int, include_view_zones? : Bool) -> Double
+pub fn CodeEditorWidget::get_top_for_position(Self, Int, Int) -> Double
+pub fn CodeEditorWidget::get_value(Self) -> String
+pub fn CodeEditorWidget::get_visible_column_from_position(Self, @common.Position) -> Int
+pub fn CodeEditorWidget::get_visible_ranges(Self) -> Array[@common.Range]
+pub fn CodeEditorWidget::get_width_of_line(Self, Int) -> Double
+pub fn CodeEditorWidget::handle_initialized(Self) -> Unit
+pub fn CodeEditorWidget::has_text_focus(Self) -> Bool
+pub fn CodeEditorWidget::has_widget_focus(Self) -> Bool
+pub fn CodeEditorWidget::is_disposed(Self) -> Bool
+pub fn CodeEditorWidget::layout(Self) -> Unit
+pub fn CodeEditorWidget::on_context_menu(Self, (@moonbitlang/editor/viewer/browser.EditorMouseEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_change_cursor_position(Self, (@editor_api.CursorPositionChangedEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_change_cursor_selection(Self, (@editor_api.CursorSelectionChangedEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_change_hidden_areas(Self, () -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_change_model(Self, (@editor_api.ModelChangedEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_change_model_content(Self, (@model.ModelContentChangedEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_change_view_zones(Self, () -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_dispose(Self, () -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_did_scroll_change(Self, (@editor_api.ScrollEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_mouse_down(Self, (@moonbitlang/editor/viewer/browser.EditorMouseEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_mouse_leave(Self, (@moonbitlang/editor/viewer/browser.PartialEditorMouseEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_mouse_move(Self, (@moonbitlang/editor/viewer/browser.EditorMouseEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::on_mouse_up(Self, (@moonbitlang/editor/viewer/browser.EditorMouseEvent) -> Unit) -> @common.Disposable
+pub fn CodeEditorWidget::remove_decorations(Self, Array[String]) -> Unit
+pub fn CodeEditorWidget::remove_overlay_widget(Self, @moonbitlang/editor/viewer/browser.OverlayWidget) -> Unit
+pub fn CodeEditorWidget::render_lines(Self, @model.TextModel, @common.LineRange, decorations? : Array[CodeEditorRenderLinesDecoration], line_class_name? : String, margin_line_class_name? : String) -> CodeEditorRenderedLines?
+pub fn CodeEditorWidget::restore_stable_viewport_state(Self, StableCodeEditorViewportState) -> Bool
+pub fn CodeEditorWidget::restore_view_state(Self, CodeEditorViewState?) -> Unit
+pub fn CodeEditorWidget::reveal_line(Self, Int) -> Unit
+pub fn CodeEditorWidget::reveal_line_in_center(Self, Int) -> Unit
+pub fn CodeEditorWidget::reveal_line_in_center_if_outside_viewport(Self, Int) -> Unit
+pub fn CodeEditorWidget::reveal_line_near_top(Self, Int) -> Unit
+pub fn CodeEditorWidget::reveal_lines(Self, Int, Int) -> Unit
+pub fn CodeEditorWidget::reveal_lines_in_center(Self, Int, Int) -> Unit
+pub fn CodeEditorWidget::reveal_lines_in_center_if_outside_viewport(Self, Int, Int) -> Unit
+pub fn CodeEditorWidget::reveal_lines_near_top(Self, Int, Int) -> Unit
+pub fn CodeEditorWidget::reveal_managed_view_zone_top(Self, String) -> Bool
+pub fn CodeEditorWidget::reveal_position(Self, @common.Position) -> Unit
+pub fn CodeEditorWidget::reveal_position_in_center(Self, @common.Position) -> Unit
+pub fn CodeEditorWidget::reveal_position_in_center_if_outside_viewport(Self, @common.Position) -> Unit
+pub fn CodeEditorWidget::reveal_position_near_top(Self, @common.Position) -> Unit
+pub fn CodeEditorWidget::reveal_range(Self, @common.Range) -> Unit
+pub fn CodeEditorWidget::reveal_range_at_top(Self, @common.Range) -> Unit
+pub fn CodeEditorWidget::reveal_range_in_center(Self, @common.Range) -> Unit
+pub fn CodeEditorWidget::reveal_range_in_center_if_outside_viewport(Self, @common.Range) -> Unit
+pub fn CodeEditorWidget::reveal_range_near_top(Self, @common.Range) -> Unit
+pub fn CodeEditorWidget::reveal_range_near_top_if_outside_viewport(Self, @common.Range) -> Unit
+pub fn CodeEditorWidget::save_view_state(Self) -> CodeEditorViewState?
+pub fn CodeEditorWidget::set_model(Self, @model.TextModel?) -> Unit
+pub fn CodeEditorWidget::set_position(Self, @common.Position, source? : String) -> Unit
+pub fn CodeEditorWidget::set_scroll_left(Self, Double) -> Unit
+pub fn CodeEditorWidget::set_scroll_position(Self, scroll_top? : Double, scroll_left? : Double) -> Unit
+pub fn CodeEditorWidget::set_scroll_top(Self, Double) -> Unit
+pub fn CodeEditorWidget::set_selection(Self, @core.Selection, source? : String) -> Unit
+pub fn CodeEditorWidget::set_selections(Self, Array[@core.Selection], source? : String, reason? : @editor_api.CursorChangeReason) -> Unit
+pub fn CodeEditorWidget::set_value(Self, String) -> Unit
+pub fn CodeEditorWidget::show_references(Self, @common.Position, Array[@language.Location]) -> Unit
+pub fn CodeEditorWidget::update_options(Self, CodeEditorOptions) -> Unit
+
+type EditorDecorationsCollection
+pub fn EditorDecorationsCollection::append(Self, Array[@model.ModelDeltaDecoration]) -> Array[String]
+pub fn EditorDecorationsCollection::clear(Self) -> Unit
+pub fn EditorDecorationsCollection::get_range(Self, Int) -> @common.Range?
+pub fn EditorDecorationsCollection::get_ranges(Self) -> Array[@common.Range]
+pub fn EditorDecorationsCollection::length(Self) -> Int
+pub fn EditorDecorationsCollection::set(Self, Array[@model.ModelDeltaDecoration]) -> Array[String]
+
+pub(all) struct ManagedCodeEditorViewZoneSpec {
+ after_line_number : Int
+ height_in_px : Double
+ dom_node : @dom.Element
+ show_in_hidden_areas : Bool
+ margin_dom_node : @dom.Element?
+ min_width_in_px : Double?
+ ordinal : Int?
+ suppress_mouse_down : Bool
+ on_dom_node_top : ((Double) -> Unit raise)?
+ on_dom_node_layout : ((Bool, Double) -> Unit raise)?
+ on_zone_registered : ((String, @moonbitlang/editor/viewer/browser.ViewZone) -> Unit)?
+}
+pub fn ManagedCodeEditorViewZoneSpec::ManagedCodeEditorViewZoneSpec(Int, Double, @dom.Element, show_in_hidden_areas? : Bool, margin_dom_node? : @dom.Element, min_width_in_px? : Double, ordinal? : Int, suppress_mouse_down? : Bool, on_dom_node_top? : (Double) -> Unit raise, on_dom_node_layout? : (Bool, Double) -> Unit raise, on_zone_registered? : (String, @moonbitlang/editor/viewer/browser.ViewZone) -> Unit) -> Self
+
+pub struct StableCodeEditorViewportState {
+ model : @model.TextModel
+ anchor_line_number : Int
+ anchor_viewport_offset : Double
+ scroll_left : Double
+}
+pub fn StableCodeEditorViewportState::anchor_line_number(Self) -> Int
+pub fn StableCodeEditorViewportState::anchor_viewport_offset(Self) -> Double
+pub fn StableCodeEditorViewportState::scroll_left(Self) -> Double
+
+pub struct VirtualizedCodeEditorRenderLinesContext {
+ editor : CodeEditorWidget
+ source_model : @model.TextModel
+ source_version : Int
+ detached_view_model : @view_model.ViewModel
+ configuration_options : CodeEditorOptions
+ configuration_font_info : @config.FontInfo
+ configuration_width : Double
+ configuration_content_width : Double
+ configuration_height : Double
+ configuration_soft_wrap_column : Int
+ owner_generation : Int
+ handles : Array[VirtualizedCodeEditorRenderedLines]
+ mut created_handle_count : Int
+ mut live_handle_count : Int
+ mut registered_zone_count : Int
+ mut disposed : Bool
+}
+pub fn VirtualizedCodeEditorRenderLinesContext::created_handle_count(Self) -> Int
+pub fn VirtualizedCodeEditorRenderLinesContext::dispose(Self) -> Unit
+pub fn VirtualizedCodeEditorRenderLinesContext::is_disposed(Self) -> Bool
+pub fn VirtualizedCodeEditorRenderLinesContext::live_handle_count(Self) -> Int
+pub fn VirtualizedCodeEditorRenderLinesContext::owner_generation(Self) -> Int
+pub fn VirtualizedCodeEditorRenderLinesContext::registered_zone_count(Self) -> Int
+pub fn VirtualizedCodeEditorRenderLinesContext::render_lines(Self, @common.LineRange, decorations? : Array[CodeEditorRenderLinesDecoration], line_class_name? : String, margin_line_class_name? : String) -> VirtualizedCodeEditorRenderedLines?
+
+pub struct VirtualizedCodeEditorRenderedLines {
+ context : VirtualizedCodeEditorRenderLinesContext
+ decorations : Array[CodeEditorRenderLinesDecoration]
+ line_class_name : String
+ margin_line_class_name : String
+ dom_node : @dom.Element
+ margin_dom_node : @dom.Element
+ range_view_start : Int
+ range_view_end_exclusive : Int
+ line_height : Double
+ height_in_px : Double
+ mut discovered_min_width_in_px : Double
+ mut zone_id : String?
+ mut zone_delegate : @moonbitlang/editor/viewer/browser.ViewZone?
+ mut rendered_start_offset : Int
+ mut rendered_end_offset_exclusive : Int
+ mut rendered_visible : Bool
+ mut disposed : Bool
+}
+pub fn VirtualizedCodeEditorRenderedLines::dispose(Self) -> Unit
+pub fn VirtualizedCodeEditorRenderedLines::dom_node(Self) -> @dom.Element
+pub fn VirtualizedCodeEditorRenderedLines::height_in_px(Self) -> Double
+pub fn VirtualizedCodeEditorRenderedLines::is_disposed(Self) -> Bool
+pub fn VirtualizedCodeEditorRenderedLines::managed_view_zone_spec(Self, Int, ordinal? : Int, suppress_mouse_down? : Bool) -> ManagedCodeEditorViewZoneSpec
+pub fn VirtualizedCodeEditorRenderedLines::margin_dom_node(Self) -> @dom.Element
+pub fn VirtualizedCodeEditorRenderedLines::min_width_in_px(Self) -> Double
+pub fn VirtualizedCodeEditorRenderedLines::reveal_zone_top(Self) -> Bool
+pub fn VirtualizedCodeEditorRenderedLines::view_line_count(Self) -> Int
+
+// Type aliases
+
+// Traits
diff --git a/editor/internal/viewer/code_editor_widget/presentation_routing.mbt b/editor/internal/viewer/code_editor_widget/presentation_routing.mbt
new file mode 100644
index 000000000..d2d2d0ecd
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/presentation_routing.mbt
@@ -0,0 +1,72 @@
+///|
+/// A `CodeEditorWidget` has exactly one presentation family: the native code
+/// editor. Keeping this predicate central lets code contributions retain their
+/// no-model guard without depending on a product-level presentation router.
+fn CodeEditorWidget::is_code_presentation(self : CodeEditorWidget) -> Bool {
+ self.model_slot.current is Some(_)
+}
+
+///|
+fn CodeEditorWidget::active_scroll_position(
+ self : CodeEditorWidget,
+) -> @view_layout.ScrollPosition? {
+ guard self.model_slot.current is Some(model_data) else { return None }
+ Some(model_data.view_model.view_layout.position())
+}
+
+///|
+fn CodeEditorWidget::active_content_width(self : CodeEditorWidget) -> Double? {
+ guard self.model_slot.current is Some(model_data) else { return None }
+ Some(model_data.view_model.view_layout.get_content_width())
+}
+
+///|
+fn CodeEditorWidget::active_scroll_width(self : CodeEditorWidget) -> Double? {
+ guard self.model_slot.current is Some(model_data) else { return None }
+ Some(model_data.view_model.view_layout.get_scroll_width())
+}
+
+///|
+fn CodeEditorWidget::active_content_height(self : CodeEditorWidget) -> Double? {
+ guard self.model_slot.current is Some(model_data) else { return None }
+ Some(model_data.view_model.view_layout.get_content_height())
+}
+
+///|
+fn CodeEditorWidget::active_scroll_height(self : CodeEditorWidget) -> Double? {
+ guard self.model_slot.current is Some(model_data) else { return None }
+ Some(model_data.view_model.view_layout.get_scroll_height())
+}
+
+///|
+fn CodeEditorWidget::set_active_scroll_position(
+ self : CodeEditorWidget,
+ scroll_top? : Double,
+ scroll_left? : Double,
+) -> Unit {
+ guard self.model_slot.current is Some(model_data) else { return }
+ let layout = model_data.view_model.view_layout
+ let position = layout.position()
+ layout.scroll_to(
+ scroll_top=scroll_top.unwrap_or(position.scroll_top),
+ scroll_left=scroll_left.unwrap_or(position.scroll_left),
+ )
+ |> ignore
+}
+
+///|
+fn CodeEditorWidget::stabilize_active_scroll(self : CodeEditorWidget) -> Unit {
+ guard self.model_slot.current is Some(model_data) else { return }
+ model_data.view_model.visible_lines_stabilized()
+}
+
+///|
+/// `ViewLayout` already owns the code editor's measured dimensions; this hook
+/// closes the outer widget's layout transaction without a second renderer.
+fn CodeEditorWidget::layout_active_presentation(
+ self : CodeEditorWidget,
+ _width : Double,
+ _height : Double,
+) -> Unit {
+ ignore(self)
+}
diff --git a/editor/viewer/public_read_api.mbt b/editor/internal/viewer/code_editor_widget/public_read_api.mbt
similarity index 85%
rename from editor/viewer/public_read_api.mbt
rename to editor/internal/viewer/code_editor_widget/public_read_api.mbt
index 85d696fc7..36bc72406 100644
--- a/editor/viewer/public_read_api.mbt
+++ b/editor/internal/viewer/code_editor_widget/public_read_api.mbt
@@ -22,7 +22,7 @@ let max_safe_column : Int = 2147483647
/// model → `''`. The `{preserveBOM, lineEnding}` option is dropped: the snapshot
/// stores fixed-LF text, retains U+FEFF as ordinary content, and exposes no
/// separate BOM metadata or EOL preference/preservation switch.
-pub fn Viewer::get_value(self : Viewer) -> String {
+pub fn CodeEditorWidget::get_value(self : CodeEditorWidget) -> String {
match self.get_model() {
None => ""
Some(model) => model.get_value()
@@ -34,34 +34,27 @@ pub fn Viewer::get_value(self : Viewer) -> String {
/// `CodeEditorWidget.getVisibleRanges` (`cew:563`) → `viewModel.getVisibleRanges`
/// (`vmi:646`): the completely-visible *view* range (from the layout) converted
/// to model ranges, split around any hidden region. No model → `[]`.
-pub fn Viewer::get_visible_ranges(self : Viewer) -> Array[@base_common.Range] {
+pub fn CodeEditorWidget::get_visible_ranges(
+ self : CodeEditorWidget,
+) -> Array[@base_common.Range] {
self.active_presentation_visible_ranges()
}
///|
-fn Viewer::active_presentation_visible_ranges(
- self : Viewer,
+fn CodeEditorWidget::active_presentation_visible_ranges(
+ self : CodeEditorWidget,
) -> Array[@base_common.Range] {
- match (self.current_markdown_document(), self.get_model()) {
- (Some(markdown), Some(model)) => {
- let source_ranges = markdown.visible_source_ranges()
- if source_ranges.is_empty() && !markdown.has_rendered_source_anchors() {
- [model.get_full_model_range()]
- } else {
- source_ranges.map(source_range => model.snapshot.range_of(source_range))
- }
- }
- _ =>
- if self.is_code_presentation() {
- self.code_visible_ranges()
- } else {
- []
- }
+ if self.is_code_presentation() {
+ self.code_visible_ranges()
+ } else {
+ []
}
}
///|
-fn Viewer::code_visible_ranges(self : Viewer) -> Array[@base_common.Range] {
+fn CodeEditorWidget::code_visible_ranges(
+ self : CodeEditorWidget,
+) -> Array[@base_common.Range] {
guard self.get_view_model() is Some(view_model) else { return [] }
let (start_view_line, end_view_line) = view_model.view_layout.completely_visible_line_range()
view_model.to_model_visible_ranges({
@@ -79,8 +72,8 @@ fn Viewer::code_visible_ranges(self : Viewer) -> Array[@base_common.Range] {
/// `linesAround = max(20, round(height / lineHeight))`, the completely-visible
/// view range grown by `linesAround` and clamped to `[1, lineCount]`, then split.
/// No model → `[]`.
-fn Viewer::get_visible_ranges_plus_viewport_above_below(
- self : Viewer,
+fn CodeEditorWidget::get_visible_ranges_plus_viewport_above_below(
+ self : CodeEditorWidget,
) -> Array[@base_common.Range] {
guard self.is_code_presentation() else { return [] }
guard self.get_view_model() is Some(view_model) else { return [] }
@@ -109,8 +102,8 @@ fn Viewer::get_visible_ranges_plus_viewport_above_below(
///|
/// Content-space top of a 1-based model line, Monaco's `getTopForLineNumber`
/// (`cew:593`). No model → `-1`.
-pub fn Viewer::get_top_for_line_number(
- self : Viewer,
+pub fn CodeEditorWidget::get_top_for_line_number(
+ self : CodeEditorWidget,
line_number : Int,
include_view_zones? : Bool = false,
) -> Double {
@@ -120,8 +113,8 @@ pub fn Viewer::get_top_for_line_number(
///|
/// 1-based "Col" value for a status bar (tabs snap to tab stops, code points),
/// Monaco's `getStatusbarColumn` (`cew:652`). No model → the raw input column.
-fn Viewer::get_statusbar_column(
- self : Viewer,
+fn CodeEditorWidget::get_statusbar_column(
+ self : CodeEditorWidget,
position : @base_common.Position,
) -> Int {
guard self.get_model() is Some(model) else { return position.column }
@@ -140,8 +133,8 @@ fn Viewer::get_statusbar_column(
/// public facade.
let _public_read_geometry_test_seams_anchor : Unit = ignore(
(
- Viewer::get_visible_ranges_plus_viewport_above_below,
- Viewer::get_statusbar_column,
+ CodeEditorWidget::get_visible_ranges_plus_viewport_above_below,
+ CodeEditorWidget::get_statusbar_column,
),
)
@@ -149,7 +142,9 @@ let _public_read_geometry_test_seams_anchor : Unit = ignore(
/// The primary cursor's model position, Monaco's `getPosition` (`cew:663`) →
/// `viewModel.getPosition`. No model → `null`; the view model's cursor always
/// exists, starting collapsed at `(1,1)` like Monaco's.
-pub fn Viewer::get_position(self : Viewer) -> @base_common.Position? {
+pub fn CodeEditorWidget::get_position(
+ self : CodeEditorWidget,
+) -> @base_common.Position? {
guard self.is_code_presentation() else { return None }
guard self.get_view_model() is Some(view_model) else { return None }
Some(view_model.cursor.model_state().position)
@@ -158,7 +153,9 @@ pub fn Viewer::get_position(self : Viewer) -> @base_common.Position? {
///|
/// The primary cursor's model selection, Monaco's `getSelection` (`cew:783`) →
/// `viewModel.getSelection`. No model → `null`.
-pub fn Viewer::get_selection(self : Viewer) -> @core.Selection? {
+pub fn CodeEditorWidget::get_selection(
+ self : CodeEditorWidget,
+) -> @core.Selection? {
guard self.is_code_presentation() else { return None }
guard self.get_view_model() is Some(view_model) else { return None }
Some(view_model.cursor.get_model_selection())
@@ -167,21 +164,21 @@ pub fn Viewer::get_selection(self : Viewer) -> @core.Selection? {
///|
/// Widest content width (horizontal scroll extent before viewport clamping),
/// Monaco's `getContentWidth` (`cew:963`). No model → `-1`.
-pub fn Viewer::get_content_width(self : Viewer) -> Double {
+pub fn CodeEditorWidget::get_content_width(self : CodeEditorWidget) -> Double {
self.active_content_width().unwrap_or(-1.0)
}
///|
/// Scrollable content width = `max(viewport width, content width)`, Monaco's
/// `getScrollWidth` (`cew:970`). No model → `-1`.
-pub fn Viewer::get_scroll_width(self : Viewer) -> Double {
+pub fn CodeEditorWidget::get_scroll_width(self : CodeEditorWidget) -> Double {
self.active_scroll_width().unwrap_or(-1.0)
}
///|
/// Current horizontal scroll offset, Monaco's `getScrollLeft` (`cew:976`). No
/// model → `-1`.
-pub fn Viewer::get_scroll_left(self : Viewer) -> Double {
+pub fn CodeEditorWidget::get_scroll_left(self : CodeEditorWidget) -> Double {
match self.active_scroll_position() {
Some(position) => position.scroll_left
None => -1.0
@@ -191,21 +188,21 @@ pub fn Viewer::get_scroll_left(self : Viewer) -> Double {
///|
/// Total content height including view zones, Monaco's `getContentHeight`
/// (`cew:983`). No model → `-1`.
-pub fn Viewer::get_content_height(self : Viewer) -> Double {
+pub fn CodeEditorWidget::get_content_height(self : CodeEditorWidget) -> Double {
self.active_content_height().unwrap_or(-1.0)
}
///|
/// Scrollable content height = `max(viewport height, content height)`, Monaco's
/// `getScrollHeight` (`cew:990`). No model → `-1`.
-pub fn Viewer::get_scroll_height(self : Viewer) -> Double {
+pub fn CodeEditorWidget::get_scroll_height(self : CodeEditorWidget) -> Double {
self.active_scroll_height().unwrap_or(-1.0)
}
///|
/// Current vertical scroll offset, Monaco's `getScrollTop` (`cew:996`). No model
/// → `-1`.
-pub fn Viewer::get_scroll_top(self : Viewer) -> Double {
+pub fn CodeEditorWidget::get_scroll_top(self : CodeEditorWidget) -> Double {
match self.active_scroll_position() {
Some(position) => position.scroll_top
None => -1.0
@@ -217,8 +214,8 @@ pub fn Viewer::get_scroll_top(self : Viewer) -> Double {
/// front half of Monaco's `_getVerticalOffsetForPosition` /
/// `_getVerticalOffsetAfterPosition` (`cew:607` / `cew:584`). `None` when there
/// is no model (the no-model sentinel boundary).
-fn Viewer::model_to_view_position(
- self : Viewer,
+fn CodeEditorWidget::model_to_view_position(
+ self : CodeEditorWidget,
line_number : Int,
column : Int,
) -> @base_common.Position? {
@@ -238,8 +235,8 @@ fn Viewer::model_to_view_position(
/// Monaco's `CodeEditorWidget._getVerticalOffsetForPosition` (`cew:607`):
/// validate → convert to view position → `viewLayout.getVerticalOffsetForLineNumber`.
/// No model → `-1`.
-fn Viewer::get_vertical_offset_for_position(
- self : Viewer,
+fn CodeEditorWidget::get_vertical_offset_for_position(
+ self : CodeEditorWidget,
line_number : Int,
column : Int,
include_view_zones~ : Bool,
@@ -259,8 +256,8 @@ fn Viewer::get_vertical_offset_for_position(
/// Monaco's `CodeEditorWidget._getVerticalOffsetAfterPosition` (`cew:584`):
/// validate → convert → `viewLayout.getVerticalOffsetAfterLineNumber`. No model
/// → `-1`.
-fn Viewer::get_vertical_offset_after_position(
- self : Viewer,
+fn CodeEditorWidget::get_vertical_offset_after_position(
+ self : CodeEditorWidget,
line_number : Int,
column : Int,
include_view_zones~ : Bool,
@@ -291,7 +288,9 @@ fn Viewer::get_vertical_offset_after_position(
/// `content_left` is the full margin (line numbers plus the line-decorations
/// lane; the glyph-margin terms are `0`). Like Monaco this does not gate on a
/// model.
-pub fn Viewer::get_layout_info(self : Viewer) -> @config.EditorLayoutInfo {
+pub fn CodeEditorWidget::get_layout_info(
+ self : CodeEditorWidget,
+) -> @config.EditorLayoutInfo {
let model_line_count = match self.get_model() {
Some(model) => model.snapshot.line_count()
// EditorConfiguration retains its one-digit model environment before any
@@ -305,8 +304,8 @@ pub fn Viewer::get_layout_info(self : Viewer) -> @config.EditorLayoutInfo {
/// Horizontal pixel offset of a model position on its rendered line, Monaco's
/// `getOffsetForColumn` (`cew:1687`) → `view.getOffsetForColumn`. No real view →
/// `-1`; line not in the rendered window → `-1`.
-pub fn Viewer::get_offset_for_column(
- self : Viewer,
+pub fn CodeEditorWidget::get_offset_for_column(
+ self : CodeEditorWidget,
line_number : Int,
column : Int,
) -> Double {
@@ -327,7 +326,10 @@ pub fn Viewer::get_offset_for_column(
/// `view.getLineWidth`: convert the line's max-column model position to its view
/// line (the last wrapped segment), then measure that view line's width. No real
/// view → `-1`; line not in the rendered window → `-1`.
-pub fn Viewer::get_width_of_line(self : Viewer, line_number : Int) -> Double {
+pub fn CodeEditorWidget::get_width_of_line(
+ self : CodeEditorWidget,
+ line_number : Int,
+) -> Double {
guard (self.get_model(), self.get_view_model(), self.current_code_view())
is (Some(model), Some(view_model), Some(view)) else {
return -1.0
@@ -350,8 +352,8 @@ pub fn Viewer::get_width_of_line(self : Viewer, line_number : Int) -> Double {
/// `getScrolledVisiblePosition` (`cew:1668`): `top` is the vertical offset minus
/// scrollTop, `left` is the column offset plus the content-left gutter minus
/// scrollLeft, `height` is the line height. No real view → `null`.
-pub fn Viewer::get_scrolled_visible_position(
- self : Viewer,
+pub fn CodeEditorWidget::get_scrolled_visible_position(
+ self : CodeEditorWidget,
position : @base_common.Position,
) -> @editor_api.ScrolledVisiblePosition? {
guard (self.get_model(), self.get_view_model(), self.current_code_view())
@@ -385,7 +387,7 @@ pub fn Viewer::get_scrolled_visible_position(
/// The content-left gutter width (Monaco's `glyphMarginWidth + lineNumbersWidth +
/// decorationsWidth`), the offset DOM-measured line x's are relative to. Backed
/// by the view model's measured `gutter_width`; `0` with no model.
-fn Viewer::read_api_content_left(self : Viewer) -> Double {
+fn CodeEditorWidget::read_api_content_left(self : CodeEditorWidget) -> Double {
match self.get_model() {
Some(model) =>
self.configuration.snapshot().gutter_width(model.snapshot.line_count())
@@ -397,7 +399,9 @@ fn Viewer::read_api_content_left(self : Viewer) -> Double {
/// Flushes a pending render so DOM-measured getters read current client rects,
/// Monaco's `_flushAccumulatedAndRenderNow`. A no-op when nothing is pending (the
/// DOM already reflects the latest content) or when there is no view.
-fn Viewer::flush_accumulated_and_render_now(self : Viewer) -> Unit {
+fn CodeEditorWidget::flush_accumulated_and_render_now(
+ self : CodeEditorWidget,
+) -> Unit {
if self.mount.has_pending_render() && !self.disposed {
self.cancel_render_frame()
self.flush_render()
diff --git a/editor/viewer/public_read_api_wbtest.mbt b/editor/internal/viewer/code_editor_widget/public_read_api_wbtest.mbt
similarity index 96%
rename from editor/viewer/public_read_api_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/public_read_api_wbtest.mbt
index 495dab7c0..9e51cba3f 100644
--- a/editor/viewer/public_read_api_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/public_read_api_wbtest.mbt
@@ -11,8 +11,8 @@
/// Injects layout-only whitespace (no DOM node), the headless analog of
/// `change_view_zones`, so the `include_view_zones` branch of the vertical-offset
/// getters can be exercised without a rendered view.
-fn Viewer::test_set_layout_view_zones(
- self : Viewer,
+fn CodeEditorWidget::test_set_layout_view_zones(
+ self : CodeEditorWidget,
zones : Array[(Int, Int)],
) -> Unit {
match self.get_view_model() {
@@ -32,7 +32,7 @@ fn Viewer::test_set_layout_view_zones(
/// No model: every getter returns its exact Monaco sentinel. The column getters
/// return the *raw input column*, not `-1` — a load-bearing distinction.
test "read API: no-model getters return Monaco sentinels" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
assert_eq(viewer.get_value(), "")
assert_eq(viewer.get_visible_ranges().length(), 0)
assert_eq(viewer.get_visible_ranges_plus_viewport_above_below().length(), 0)
@@ -58,7 +58,7 @@ test "read API: no-model getters return Monaco sentinels" {
/// No model: the scroll setters no-op (`cew:1003-1029`) and `save_view_state`
/// returns `None` (`cew:1038-1041`).
test "read API: no-model scroll writes no-op and view state is None" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
viewer.set_scroll_top(120.0)
viewer.set_scroll_left(80.0)
viewer.set_scroll_position(scroll_top=50.0, scroll_left=60.0)
@@ -75,7 +75,7 @@ test "read API: no-model scroll writes no-op and view state is None" {
/// (the layout lives on the per-model view model, `viewModelImpl.ts:126`) —
/// no manual reset runs in `set_model`.
test "read API: set_model twice starts the new model at the origin" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
viewer.set_model(Some(test_model(long_document(200))))
viewer.test_set_viewport(width=800.0, height=180.0)
viewer.set_scroll_top(500.0)
@@ -239,7 +239,7 @@ test "read API: visible ranges plus viewport padding grows the window" {
/// at column 6 makes 2 view lines for 1 model line; the visible range is the one
/// model line.
test "read API: visible ranges convert view to model coordinates under wrap" {
- let options = ViewerOptions(soft_wrap=true)
+ let options = CodeEditorOptions(soft_wrap=true)
with_test_viewer(
"abcdefghij",
viewer => {
@@ -261,7 +261,7 @@ test "read API: visible ranges convert view to model coordinates under wrap" {
/// stop 4; column 2 (just after the tab) reports visible column 5. Past-EOL
/// columns are validated down to the line's max column first.
test "read API: visible and statusbar columns expand tabs" {
- let options = ViewerOptions(tab_size=4)
+ let options = CodeEditorOptions(tab_size=4)
with_test_viewer(
"\tabc",
viewer => {
@@ -281,7 +281,7 @@ test "read API: visible and statusbar columns expand tabs" {
/// Selection and position continue to report model coordinates while wrapping
/// is active; the wrapped presentation does not leak view-line coordinates.
test "read API: selection and position report model coordinates under wrap" {
- let options = ViewerOptions(soft_wrap=true)
+ let options = CodeEditorOptions(soft_wrap=true)
with_test_viewer(
"abcdefghij",
viewer => {
@@ -364,7 +364,7 @@ test "read API: layout info reduces to gutter + content box" {
assert_true(info.content_left > 0.0)
// `showFoldingControls: 'never'` drops the reserve back to the raw
// `lineDecorationsWidth`.
- viewer.update_options(ViewerOptions(show_folding_controls=Never))
+ viewer.update_options(CodeEditorOptions(show_folding_controls=Never))
assert_eq(viewer.get_layout_info().decorations_width, 10.0)
})
}
diff --git a/editor/viewer/quick_diff_contribution.mbt b/editor/internal/viewer/code_editor_widget/quick_diff_contribution.mbt
similarity index 94%
rename from editor/viewer/quick_diff_contribution.mbt
rename to editor/internal/viewer/code_editor_widget/quick_diff_contribution.mbt
index fbbf0d8e0..c6ca5641c 100644
--- a/editor/viewer/quick_diff_contribution.mbt
+++ b/editor/internal/viewer/code_editor_widget/quick_diff_contribution.mbt
@@ -15,6 +15,7 @@ fn register_quick_diff_contribution(
) -> Unit {
registry.register_editor_contribution(
"quickDiff.decorator",
+ QuickDiff,
Eager,
viewer => {
QuickDiff(
@@ -63,9 +64,9 @@ fn register_quick_diff_contribution(
///|
/// Typed view over the central contribution row. Quick diff has no feature
-/// package singleton: this state is owned only by `Viewer.contributions`.
-fn Viewer::quick_diff_contribution(
- self : Viewer,
+/// package singleton: this state is owned only by `CodeEditorWidget.contributions`.
+fn CodeEditorWidget::quick_diff_contribution(
+ self : CodeEditorWidget,
) -> QuickDiffContributionState? {
match self.contributions.lookup("quickDiff.decorator") {
Some(entry) =>
diff --git a/editor/viewer/quick_diff_host.mbt b/editor/internal/viewer/code_editor_widget/quick_diff_host.mbt
similarity index 90%
rename from editor/viewer/quick_diff_host.mbt
rename to editor/internal/viewer/code_editor_widget/quick_diff_host.mbt
index bb53d0b5b..5cd9c4c3d 100644
--- a/editor/viewer/quick_diff_host.mbt
+++ b/editor/internal/viewer/code_editor_widget/quick_diff_host.mbt
@@ -1,7 +1,7 @@
// The editor-side half of `vscode/src/vs/workbench/contrib/scm/browser/
// quickDiffDecorator.ts` (checked-in vscode submodule): what
// `QuickDiffDecorator` does with its `codeEditor` — own a decorations
-// collection and refill it from the quick-diff changes. The `Viewer`-touching
+// collection and refill it from the quick-diff changes. The `CodeEditorWidget`-touching
// body lives here as root composition glue; the pure pieces (change
// computation and decoration construction) live in
// `internal/viewer/contrib/quick_diff/`. Those packages are dependencies of
@@ -14,8 +14,8 @@
/// the service stores one original per resource, so both collapse into one
/// synchronous pass: no model, or no stored original for it, clears the
/// collection (the decorator disposal path upstream).
-fn Viewer::quick_diff_update(
- self : Viewer,
+fn CodeEditorWidget::quick_diff_update(
+ self : CodeEditorWidget,
collection : EditorDecorationsCollection,
) -> Unit {
if !self.is_code_presentation() {
diff --git a/editor/viewer/quick_diff_host_wbtest.mbt b/editor/internal/viewer/code_editor_widget/quick_diff_host_wbtest.mbt
similarity index 92%
rename from editor/viewer/quick_diff_host_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/quick_diff_host_wbtest.mbt
index 61ae3b6af..d88ac3687 100644
--- a/editor/viewer/quick_diff_host_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/quick_diff_host_wbtest.mbt
@@ -22,7 +22,7 @@ fn quick_diff_gutter_decorations_for_model(
}
///|
-fn quick_diff_gutter_decorations(viewer : Viewer) -> Array[String] {
+fn quick_diff_gutter_decorations(viewer : CodeEditorWidget) -> Array[String] {
guard viewer.get_model() is Some(model) else { return [] }
quick_diff_gutter_decorations_for_model(model)
}
@@ -31,7 +31,7 @@ fn quick_diff_gutter_decorations(viewer : Viewer) -> Array[String] {
test "quick diff: stored original decorates the gutter on model attach" {
let backings = RootTestServiceBackings::new()
let quick_diff = backings.quick_diff
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let resource = try! @base_common.Uri::from(
scheme="inmemory",
authority="model",
@@ -68,7 +68,7 @@ test "quick diff: stored original decorates the gutter on model attach" {
test "quick diff: original arriving after attach updates, clearing removes" {
let backings = RootTestServiceBackings::new()
let quick_diff = backings.quick_diff
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let resource = try! @base_common.Uri::from(
scheme="inmemory",
authority="model",
@@ -105,7 +105,7 @@ test "quick diff: original arriving after attach updates, clearing removes" {
test "quick diff: an original for another resource does not decorate" {
let backings = RootTestServiceBackings::new()
let quick_diff = backings.quick_diff
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let other = try! @base_common.Uri::from(
scheme="inmemory",
authority="model",
@@ -131,7 +131,7 @@ test "quick diff: an original for another resource does not decorate" {
test "quick diff: identical original decorates nothing" {
let backings = RootTestServiceBackings::new()
let quick_diff = backings.quick_diff
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let resource = try! @base_common.Uri::from(
scheme="inmemory",
authority="model",
@@ -148,7 +148,7 @@ test "quick diff: identical original decorates nothing" {
test "quick diff: one central state follows model swap, removal, and reattach" {
let backings = RootTestServiceBackings::new()
let quick_diff = backings.quick_diff
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let state = viewer.quick_diff_contribution().unwrap()
let resource = try! @base_common.Uri::from(
scheme="inmemory",
@@ -202,7 +202,7 @@ test "quick diff: one central state follows model swap, removal, and reattach" {
test "quick diff: disposal window is live and later service events are silent" {
let backings = RootTestServiceBackings::new()
let quick_diff = backings.quick_diff
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let state = viewer.quick_diff_contribution().unwrap()
let resource = try! @base_common.Uri::from(
scheme="inmemory",
diff --git a/editor/internal/viewer/code_editor_widget/references_contribution.mbt b/editor/internal/viewer/code_editor_widget/references_contribution.mbt
new file mode 100644
index 000000000..cfaeb04c5
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/references_contribution.mbt
@@ -0,0 +1,81 @@
+///|
+/// Monaco's shared Definition/References Peek controller id. Each
+/// CodeEditorWidget owns one shared-controller instance; Markdown surfaces
+/// construct a separate instance with their own host adapter.
+let references_contribution_id : String = "editor.contrib.referencesController"
+
+///|
+priv struct ReferencesContributionState {
+ controller : @references_browser.ReferencesController
+ mut disposed : Bool
+}
+
+///|
+fn ReferencesContributionState::ReferencesContributionState(
+ viewer : CodeEditorWidget,
+) -> ReferencesContributionState {
+ { controller: viewer.create_references_controller(), disposed: false }
+}
+
+///|
+fn ReferencesContributionState::dispose(
+ self : ReferencesContributionState,
+ _viewer : CodeEditorWidget,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.controller.dispose()
+}
+
+///|
+fn register_references_contribution(
+ registry : EditorContributionRegistry,
+) -> Unit {
+ registry.register_editor_contribution(
+ references_contribution_id,
+ References,
+ Lazy,
+ viewer => References(ReferencesContributionState(viewer)),
+ _viewer => [],
+ )
+ let goto_references = @goto_symbol.GoToReferences
+ registry.register_editor_command(
+ goto_references.id(),
+ viewer => viewer.references_action_available(),
+ viewer => viewer.start_goto_references(),
+ [],
+ shift_keybindings=[("F12", false, false)],
+ title=goto_references.title(),
+ menu_keybinding_label=goto_references.keybinding_label().unwrap(),
+ menu_placements=[
+ { menu_id: EditorContext, group: "navigation", order: 1.3 },
+ ],
+ )
+ let peek_references = @goto_symbol.PeekReferences
+ registry.register_editor_command(
+ peek_references.id(),
+ viewer => viewer.references_action_available(),
+ viewer => viewer.start_peek_references(),
+ [],
+ title=peek_references.title(),
+ menu_placements=[
+ { menu_id: EditorContext, group: "navigation", order: 1.4 },
+ ],
+ )
+}
+
+///|
+fn CodeEditorWidget::references_contribution(
+ self : CodeEditorWidget,
+) -> ReferencesContributionState? {
+ match self.contributions.lookup(references_contribution_id) {
+ Some(entry) =>
+ match entry.instance {
+ References(state) => Some(state)
+ _ => None
+ }
+ None => None
+ }
+}
diff --git a/editor/internal/viewer/code_editor_widget/render_fence_wbtest.mbt b/editor/internal/viewer/code_editor_widget/render_fence_wbtest.mbt
new file mode 100644
index 000000000..fc8472a22
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/render_fence_wbtest.mbt
@@ -0,0 +1,20 @@
+///|
+test "after-next-render is one-shot generation fenced and cancellable" {
+ let viewer = CodeEditorWidget()
+ let calls : Array[String] = []
+ viewer.after_next_render(() => {
+ calls.push("first")
+ viewer.after_next_render(() => calls.push("second")) |> ignore
+ })
+ |> ignore
+ let cancelled = viewer.after_next_render(() => calls.push("cancelled"))
+ cancelled.dispose()
+
+ viewer.flush_render()
+ assert_true(calls == ["first"])
+ viewer.flush_render()
+ assert_true(calls == ["first", "second"])
+ viewer.flush_render()
+ assert_true(calls == ["first", "second"])
+ viewer.dispose()
+}
diff --git a/editor/viewer/render_whitespace_options_wbtest.mbt b/editor/internal/viewer/code_editor_widget/render_whitespace_options_wbtest.mbt
similarity index 91%
rename from editor/viewer/render_whitespace_options_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/render_whitespace_options_wbtest.mbt
index bb93103f2..23762dbb8 100644
--- a/editor/viewer/render_whitespace_options_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/render_whitespace_options_wbtest.mbt
@@ -1,6 +1,6 @@
///|
test "viewer options default to Selection whitespace and control characters on" {
- let options = ViewerOptions::default()
+ let options = CodeEditorOptions::default()
assert_true(options.render_whitespace == Selection)
assert_true(options.render_control_characters)
}
@@ -9,7 +9,7 @@ test "viewer options default to Selection whitespace and control characters on"
/// Mirrors the reviewed constructor boundary in
/// `EditorConfigurationSnapshot::view_lines_configuration`. Equality keeps the
/// browser package's representation abstract while proving every retained
-/// Viewer option and measured font axis crosses that boundary unchanged. The
+/// CodeEditorWidget option and measured font axis crosses that boundary unchanged. The
/// paired browser-view tests then feed this constructor into the private
/// per-line render-input adapter.
fn expected_view_lines_configuration(
@@ -53,7 +53,7 @@ test "viewer configuration carries each content-only whitespace mode" {
)
assert_true(snapshot.options.render_whitespace == mode)
},
- options=ViewerOptions(render_whitespace=mode),
+ options=CodeEditorOptions(render_whitespace=mode),
)
}
}
@@ -70,7 +70,7 @@ test "viewer configuration carries render-control characters" {
)
assert_true(snapshot.options.render_control_characters)
},
- options=ViewerOptions(render_control_characters=true),
+ options=CodeEditorOptions(render_control_characters=true),
)
}
diff --git a/editor/viewer/restore_view_state_tokenization_wbtest.mbt b/editor/internal/viewer/code_editor_widget/restore_view_state_tokenization_wbtest.mbt
similarity index 92%
rename from editor/viewer/restore_view_state_tokenization_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/restore_view_state_tokenization_wbtest.mbt
index 5941949ec..a9728f121 100644
--- a/editor/viewer/restore_view_state_tokenization_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/restore_view_state_tokenization_wbtest.mbt
@@ -41,8 +41,8 @@ test "restore view state commits scroll before stabilized visible lines" {
RestoreStateCountingTokenizer::{ calls, },
)
defer registration.dispose()
- let viewer = Viewer::Viewer(
- options=ViewerOptions(font_size=12, line_height=18),
+ let viewer = CodeEditorWidget::CodeEditorWidget(
+ options=CodeEditorOptions(font_size=12, line_height=18),
)
defer viewer.dispose()
let model = test_model(
@@ -86,8 +86,8 @@ test "None restore and repeated render do not publish stabilized visible lines"
RestoreStateCountingTokenizer::{ calls, },
)
defer registration.dispose()
- let viewer = Viewer::Viewer(
- options=ViewerOptions(font_size=12, line_height=18),
+ let viewer = CodeEditorWidget::CodeEditorWidget(
+ options=CodeEditorOptions(font_size=12, line_height=18),
)
defer viewer.dispose()
let model = test_model(
@@ -119,8 +119,8 @@ test "restore view state is a no-op without a model" {
RestoreStateCountingTokenizer::{ calls, },
)
defer registration.dispose()
- let viewer = Viewer::Viewer(
- options=ViewerOptions(font_size=12, line_height=18),
+ let viewer = CodeEditorWidget::CodeEditorWidget(
+ options=CodeEditorOptions(font_size=12, line_height=18),
)
defer viewer.dispose()
let model = test_model(
diff --git a/editor/viewer/reveal.mbt b/editor/internal/viewer/code_editor_widget/reveal.mbt
similarity index 92%
rename from editor/viewer/reveal.mbt
rename to editor/internal/viewer/code_editor_widget/reveal.mbt
index 782c3681a..62ec9a3a8 100644
--- a/editor/viewer/reveal.mbt
+++ b/editor/internal/viewer/code_editor_widget/reveal.mbt
@@ -11,8 +11,8 @@
///|
/// Reveals a 1-based model position, aligning its top (`Simple`) and revealing
/// it horizontally.
-pub fn Viewer::reveal_position(
- self : Viewer,
+pub fn CodeEditorWidget::reveal_position(
+ self : CodeEditorWidget,
position : @base_common.Position,
) -> Unit {
self.reveal_position_with(position, Simple)
@@ -21,16 +21,16 @@ pub fn Viewer::reveal_position(
///|
/// Reveals a 1-based model position centered only if it is currently outside
/// the viewport (`CenterIfOutsideViewport`), horizontally too.
-pub fn Viewer::reveal_position_in_center_if_outside_viewport(
- self : Viewer,
+pub fn CodeEditorWidget::reveal_position_in_center_if_outside_viewport(
+ self : CodeEditorWidget,
position : @base_common.Position,
) -> Unit {
self.reveal_position_with(position, CenterIfOutsideViewport)
}
///|
-fn Viewer::reveal_line_with(
- self : Viewer,
+fn CodeEditorWidget::reveal_line_with(
+ self : CodeEditorWidget,
line_number : Int,
vertical_type : @view_layout.VerticalRevealType,
) -> Unit {
@@ -40,8 +40,8 @@ fn Viewer::reveal_line_with(
}
///|
-fn Viewer::reveal_lines_with(
- self : Viewer,
+fn CodeEditorWidget::reveal_lines_with(
+ self : CodeEditorWidget,
start_line_number : Int,
end_line_number : Int,
vertical_type : @view_layout.VerticalRevealType,
@@ -54,8 +54,8 @@ fn Viewer::reveal_lines_with(
}
///|
-fn Viewer::reveal_position_with(
- self : Viewer,
+fn CodeEditorWidget::reveal_position_with(
+ self : CodeEditorWidget,
position : @base_common.Position,
vertical_type : @view_layout.VerticalRevealType,
) -> Unit {
@@ -72,8 +72,8 @@ fn Viewer::reveal_position_with(
///|
/// Presentation-routing entry for every public model-coordinate reveal.
-fn Viewer::send_reveal_range(
- self : Viewer,
+fn CodeEditorWidget::send_reveal_range(
+ self : CodeEditorWidget,
model_range : @base_common.Range,
vertical_type : @view_layout.VerticalRevealType,
reveal_horizontal~ : Bool,
@@ -81,24 +81,6 @@ fn Viewer::send_reveal_range(
minimal_reveal? : Bool = false,
scroll_type? : CursorRevealScrollType = Immediate,
) -> Unit {
- match (self.current_markdown_document(), self.get_model()) {
- (Some(markdown), Some(model)) => {
- let range = model.validate_range(model_range)
- let source_offset = model.snapshot.get_offset_at(
- range.start_line_number,
- range.start_column,
- )
- let alignment = match vertical_type {
- Center => "center"
- CenterIfOutsideViewport | NearTopIfOutsideViewport => "nearest"
- Bottom => "end"
- Simple | NearTop | Top => "start"
- }
- markdown.reveal_source_offset(source_offset, alignment)
- return
- }
- _ => ()
- }
guard self.is_code_presentation() else { return }
self.send_code_reveal_range(
model_range,
@@ -114,8 +96,8 @@ fn Viewer::send_reveal_range(
/// The Code reveal pipeline, Monaco's `CodeEditorWidget._sendRevealRange`
/// (`codeEditorWidget.ts:685`) folded together with
/// `ViewLines.onRevealRangeRequest` (`viewLines.ts:269`).
-fn Viewer::send_code_reveal_range(
- self : Viewer,
+fn CodeEditorWidget::send_code_reveal_range(
+ self : CodeEditorWidget,
model_range : @base_common.Range,
vertical_type : @view_layout.VerticalRevealType,
reveal_horizontal~ : Bool,
@@ -146,8 +128,8 @@ fn Viewer::send_code_reveal_range(
/// already resolved wrap/injected-text ambiguity; converting its model
/// position back to view space can choose a different projection, so command
/// reveal paths enter below the model-to-view boundary.
-fn Viewer::reveal_view_cursor(
- self : Viewer,
+fn CodeEditorWidget::reveal_view_cursor(
+ self : CodeEditorWidget,
horizontal~ : Bool,
source? : String = "",
minimal_reveal? : Bool = false,
@@ -208,7 +190,7 @@ fn apply_cursor_reveal_scroll_position(
}
///|
-/// Builds the browser-only single-line request without mutating Viewer state.
+/// Builds the browser-only single-line request without mutating CodeEditorWidget state.
/// A multi-line horizontal reveal deliberately returns `None` without clearing
/// an older request; its scroll-to-base change normally performs cancellation.
fn horizontal_reveal_request_for_range(
@@ -246,8 +228,8 @@ fn CodeBrowserData::clear_horizontal_reveal(self : CodeBrowserData) -> Unit {
}
///|
-fn Viewer::send_reveal_view_range(
- self : Viewer,
+fn CodeEditorWidget::send_reveal_view_range(
+ self : CodeEditorWidget,
view_range : @base_common.Range,
vertical_type : @view_layout.VerticalRevealType,
reveal_horizontal~ : Bool,
diff --git a/editor/viewer/reveal_wbtest.mbt b/editor/internal/viewer/code_editor_widget/reveal_wbtest.mbt
similarity index 98%
rename from editor/viewer/reveal_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/reveal_wbtest.mbt
index e3f221a62..c88237d10 100644
--- a/editor/viewer/reveal_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/reveal_wbtest.mbt
@@ -1,4 +1,4 @@
-// Phase 2 behavior tests for the `Viewer::reveal*` surface, on the headless
+// Phase 2 behavior tests for the `CodeEditorWidget::reveal*` surface, on the headless
// `with_test_viewer` harness (no DOM): an unattached viewer owns a full model +
// ViewModel but no `CodeBrowserData`, so reveals drive model→view conversion
// and vertical scroll math without retaining browser-only requests. Pure
diff --git a/editor/viewer/selection.mbt b/editor/internal/viewer/code_editor_widget/selection.mbt
similarity index 94%
rename from editor/viewer/selection.mbt
rename to editor/internal/viewer/code_editor_widget/selection.mbt
index baa7c626a..f4bb9f825 100644
--- a/editor/viewer/selection.mbt
+++ b/editor/internal/viewer/code_editor_widget/selection.mbt
@@ -5,7 +5,9 @@
/// in view coordinates (its `viewState`), so the overlay never round-trips
/// through raw document offsets. Columns are one-based and local to each view
/// line (the convention `measure_line_selection` consumes).
-fn Viewer::selection_view_range(self : Viewer) -> @base_common.Range? {
+fn CodeEditorWidget::selection_view_range(
+ self : CodeEditorWidget,
+) -> @base_common.Range? {
guard self.is_code_presentation() else { return None }
guard self.get_view_model() is Some(view_model) else { return None }
let selection = view_model.cursor.get_view_selection()
@@ -21,7 +23,9 @@ fn Viewer::selection_view_range(self : Viewer) -> @base_common.Range? {
/// `selection_view_range` it survives collapsing, so a caret-only
/// `set_position` still renders. `None` only without a model — the view
/// model's cursor always exists, collapsed at (1,1) like Monaco's.
-fn Viewer::view_selection(self : Viewer) -> @core.Selection? {
+fn CodeEditorWidget::view_selection(
+ self : CodeEditorWidget,
+) -> @core.Selection? {
guard self.is_code_presentation() else { return None }
match self.get_view_model() {
Some(view_model) => Some(view_model.cursor.get_view_selection())
@@ -36,8 +40,8 @@ fn Viewer::view_selection(self : Viewer) -> @core.Selection? {
/// selection path. No model → no-op, no event. Like every Monaco mutator this
/// never scrolls; "move and reveal" is a composition with `reveal_*` at the
/// call site (the `gotoLocation` pattern).
-pub fn Viewer::set_position(
- self : Viewer,
+pub fn CodeEditorWidget::set_position(
+ self : CodeEditorWidget,
position : @base_common.Position,
source? : String = "api",
) -> Unit {
@@ -54,8 +58,8 @@ pub fn Viewer::set_position(
/// active position and the resulting direction is preserved; a caller with a
/// plain range builds `Selection::from_positions(start, end)`. No model →
/// no-op, no event. Never scrolls (see `set_position`).
-pub fn Viewer::set_selection(
- self : Viewer,
+pub fn CodeEditorWidget::set_selection(
+ self : CodeEditorWidget,
selection : @core.Selection,
source? : String = "api",
) -> Unit {
@@ -67,8 +71,8 @@ pub fn Viewer::set_selection(
/// version snapshots, view-state commit, and outgoing delivery all belong to
/// the ViewModel/controller spine; API setters contribute only source `api`
/// and neutral reason `NotSet`.
-fn Viewer::apply_selection(
- self : Viewer,
+fn CodeEditorWidget::apply_selection(
+ self : CodeEditorWidget,
selection : @core.Selection,
source : String,
reason : @editor_api.CursorChangeReason,
@@ -91,8 +95,8 @@ fn Viewer::apply_selection(
let caller_owned_editor_dom_selector : String = "[monaco-view-zone], [data-content-widget], [data-overlay-widget-id]"
///|
-fn Viewer::handle_copy_event(
- self : Viewer,
+fn CodeEditorWidget::handle_copy_event(
+ self : CodeEditorWidget,
event : @rdom.Event,
input_surface : @rdom.Element,
) -> Bool {
@@ -124,8 +128,8 @@ fn Viewer::handle_copy_event(
}
///|
-fn Viewer::get_rich_text_to_copy(
- self : Viewer,
+fn CodeEditorWidget::get_rich_text_to_copy(
+ self : CodeEditorWidget,
selection : @core.Selection,
snapshot : @model.TextSnapshot,
) -> String {
diff --git a/editor/viewer/selection_clipboard_wbtest.mbt b/editor/internal/viewer/code_editor_widget/selection_clipboard_wbtest.mbt
similarity index 100%
rename from editor/viewer/selection_clipboard_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/selection_clipboard_wbtest.mbt
diff --git a/editor/viewer/services.mbt b/editor/internal/viewer/code_editor_widget/services.mbt
similarity index 86%
rename from editor/viewer/services.mbt
rename to editor/internal/viewer/code_editor_widget/services.mbt
index a935cf809..e06953b47 100644
--- a/editor/viewer/services.mbt
+++ b/editor/internal/viewer/code_editor_widget/services.mbt
@@ -1,13 +1,13 @@
///|
-/// Opaque service capabilities shared by the embeddable Viewer and its
+/// Opaque service capabilities shared by the embeddable CodeEditorWidget and its
/// feature controllers. Concrete host backings never cross this root facade.
///
/// The hover participant registry is *not* held here: it is a free global in
/// `internal/viewer/contrib/hover` populated by that feature's contribution,
-/// mirroring Monaco's `HoverParticipantRegistry`. `Viewer::request_hover`
+/// mirroring Monaco's `HoverParticipantRegistry`. `CodeEditorWidget::request_hover`
/// projects these services into a `@hover.HoverParticipantServices` when it
/// builds a hover computer.
-struct ViewerServices {
+struct CodeEditorServices {
languages : @languages.LanguageHandle
marker_decorations : @markers.MarkerDecorationsHandle
agent_feedback : @agent_feedback_api.AgentFeedbackHandle
@@ -25,11 +25,11 @@ struct ViewerServices {
///|
/// Creates a service bundle. Passing the returned value explicitly to a
-/// Viewer keeps ownership with the caller, allowing multiple Viewers to share
-/// model registrations and host state. Omitting the Viewer constructor's
+/// CodeEditorWidget keeps ownership with the caller, allowing multiple Viewers to share
+/// model registrations and host state. Omitting the CodeEditorWidget constructor's
/// `services` argument creates the same shape internally and transfers only
-/// that internal bundle's lifetime to the Viewer.
-pub fn ViewerServices::ViewerServices(
+/// that internal bundle's lifetime to the CodeEditorWidget.
+pub fn CodeEditorServices::CodeEditorServices(
languages? : @languages.LanguageHandle,
markers? : @markers.MarkerDecorationsSource,
agent_feedback? : @agent_feedback_api.AgentFeedbackHandle,
@@ -37,7 +37,7 @@ pub fn ViewerServices::ViewerServices(
text_model_resolver? : @navigation_api.TextModelResolverHandle,
quick_diff? : @quick_diff_api.QuickDiffHandle,
log_service? : @log.LogHandle,
-) -> ViewerServices {
+) -> CodeEditorServices {
let log_service = match log_service {
Some(handle) => handle
None => @log.LogService().log_handle()
@@ -93,7 +93,7 @@ pub fn ViewerServices::ViewerServices(
/// Releases only backings created by this bundle, in the fixed
/// marker-decorations → marker-store → feedback order. The operation is
/// idempotent and never disposes a caller-supplied handle or captured backing.
-pub fn ViewerServices::dispose(self : ViewerServices) -> Unit {
+pub fn CodeEditorServices::dispose(self : CodeEditorServices) -> Unit {
if self.disposed {
return
}
diff --git a/editor/viewer/set_value_api_wbtest.mbt b/editor/internal/viewer/code_editor_widget/set_value_api_wbtest.mbt
similarity index 94%
rename from editor/viewer/set_value_api_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/set_value_api_wbtest.mbt
index 3719c3d96..e987f2ecf 100644
--- a/editor/viewer/set_value_api_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/set_value_api_wbtest.mbt
@@ -47,7 +47,7 @@ test "set_value re-fires onDidChangeModelContent with the flush payload" {
///|
test "set_value without a model is a no-op (cew:490-492)" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
viewer.set_value("anything")
assert_true(viewer.get_model() is None)
viewer.dispose()
@@ -127,7 +127,7 @@ test "set_value resets the cursor to the origin" {
///|
test "quick diff re-renders its gutter decorations after a flush (Track E)" {
let backings = RootTestServiceBackings::new()
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let model = test_model("alpha\nbravo")
backings.quick_diff.set_original_content(model.uri, Some("alpha\nbravo"))
viewer.set_model(Some(model))
@@ -144,7 +144,7 @@ test "quick diff re-renders its gutter decorations after a flush (Track E)" {
///|
test "marker decorations re-seed after a flush (Track E)" {
let backings = RootTestServiceBackings::new()
- let viewer = Viewer::Viewer(services=backings.services)
+ let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services)
let model = test_model("alpha\nbravo")
// Host diagnostics may predate attachment. Acquiring the model seeds them;
// ordinary detach and set_value never clear the host-owned store.
@@ -163,7 +163,7 @@ test "marker decorations re-seed after a flush (Track E)" {
assert_eq(marker_handle.get_live_markers_for_model(model).length(), 1)
viewer.set_value("alpha\nCHANGED")
// The service's one per-identity content watch re-seeded the decoration;
- // Viewer content delivery does not acquire another lease.
+ // CodeEditorWidget content delivery does not acquire another lease.
assert_eq(marker_handle.get_live_markers_for_model(model).length(), 1)
viewer.dispose()
backings.dispose()
diff --git a/editor/viewer/set_value_tokenization_ranges_wbtest.mbt b/editor/internal/viewer/code_editor_widget/set_value_tokenization_ranges_wbtest.mbt
similarity index 95%
rename from editor/viewer/set_value_tokenization_ranges_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/set_value_tokenization_ranges_wbtest.mbt
index af2bf9a26..60d6473c0 100644
--- a/editor/viewer/set_value_tokenization_ranges_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/set_value_tokenization_ranges_wbtest.mbt
@@ -12,8 +12,8 @@ fn assert_flush_token_range_domains(
) -> Unit raise {
let model = test_model(old_text, language_id="flush-range-domain")
defer model.dispose()
- let viewer = Viewer::Viewer(
- options=ViewerOptions(font_size=12, line_height=18),
+ let viewer = CodeEditorWidget::CodeEditorWidget(
+ options=CodeEditorOptions(font_size=12, line_height=18),
)
defer viewer.dispose()
viewer.set_model(Some(model))
diff --git a/editor/viewer/test_viewer_harness_wbtest.mbt b/editor/internal/viewer/code_editor_widget/test_viewer_harness_wbtest.mbt
similarity index 83%
rename from editor/viewer/test_viewer_harness_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/test_viewer_harness_wbtest.mbt
index 6b9d6e0dd..c876b0bc9 100644
--- a/editor/viewer/test_viewer_harness_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/test_viewer_harness_wbtest.mbt
@@ -1,11 +1,11 @@
// Headless behavior-test harness — the MoonBit analog of vscode's
-// editor/test/browser/testCodeEditor.ts. Drives a real Viewer + ViewModel +
+// editor/test/browser/testCodeEditor.ts. Drives a real CodeEditorWidget + ViewModel +
// CursorsController over an in-memory model WITHOUT attaching a DOM view
// (mirrors TestCodeEditor._createView returning null), so tests assert on
// semantic state — positions, ranges, projected view lines, frames — instead
// of rendered pixels.
//
-// The seam is `Viewer::attach_model` (attach_model.mbt): it builds the
+// The seam is `CodeEditorWidget::attach_model` (attach_model.mbt): it builds the
// tokenized document, view model, and cursor context and stores
// them synchronously before `schedule_render`. Only the DOM flush is deferred,
// and `schedule_render`/`flush_render` already no-op while the model has no
@@ -44,10 +44,10 @@ fn test_model(
/// likewise runs over a fixed `TestConfiguration`, not the host font.
fn with_test_viewer(
text : String,
- body : (Viewer) -> Unit raise,
- options? : ViewerOptions = ViewerOptions(font_size=12, line_height=18),
+ body : (CodeEditorWidget) -> Unit raise,
+ options? : CodeEditorOptions = CodeEditorOptions(font_size=12, line_height=18),
) -> Unit raise {
- let viewer = Viewer::Viewer(options~) // NOTE: deliberately no attach()
+ let viewer = CodeEditorWidget::CodeEditorWidget(options~) // NOTE: deliberately no attach()
viewer.set_model(Some(test_model(text)))
body(viewer)
viewer.dispose()
@@ -62,14 +62,18 @@ extern "js" fn headless_test_element() -> @rdom.Element = "() => ({})"
///|
/// `ITestCodeEditor.getViewModel()` — whitebox read of the private field.
-fn Viewer::test_view_model(self : Viewer) -> @view_model.ViewModel {
+fn CodeEditorWidget::test_view_model(
+ self : CodeEditorWidget,
+) -> @view_model.ViewModel {
self.get_view_model().unwrap()
}
///|
/// `ITestCodeEditor`'s cursor read — the view model's always-present
/// controller (`viewModelImpl.ts:124`).
-fn Viewer::test_cursor(self : Viewer) -> @cursor.CursorsController {
+fn CodeEditorWidget::test_cursor(
+ self : CodeEditorWidget,
+) -> @cursor.CursorsController {
self.test_view_model().cursor
}
@@ -77,7 +81,9 @@ fn Viewer::test_cursor(self : Viewer) -> @cursor.CursorsController {
/// The clamped view-line window the viewer would render for the layout's
/// current visible window — the headless analog of `synced_window`, which
/// only runs inside a DOM flush.
-fn Viewer::test_window(self : Viewer) -> @view_layout.LineWindow {
+fn CodeEditorWidget::test_window(
+ self : CodeEditorWidget,
+) -> @view_layout.LineWindow {
let view_model = self.test_view_model()
view_model.clamp_window(view_model.view_layout.visible_window())
}
@@ -85,10 +91,13 @@ fn Viewer::test_window(self : Viewer) -> @view_layout.LineWindow {
///|
/// Forces a soft-wrap column and reflows the view model, the headless analog
/// of the wrap width an attached viewer derives from its measured content box.
-/// `ViewerOptions` carries no wrap-column field (the real column is measured
+/// `CodeEditorOptions` carries no wrap-column field (the real column is measured
/// from the DOM via `soft_wrap_column_for_content_width`), so a headless wrap
/// test sets the private column directly. Requires `options.soft_wrap`.
-fn Viewer::test_set_soft_wrap_column(self : Viewer, column : Int) -> Unit {
+fn CodeEditorWidget::test_set_soft_wrap_column(
+ self : CodeEditorWidget,
+ column : Int,
+) -> Unit {
self.configuration.commit_soft_wrap_column(column) |> ignore
self.on_configuration_changed()
}
@@ -96,8 +105,8 @@ fn Viewer::test_set_soft_wrap_column(self : Viewer, column : Int) -> Unit {
///|
/// Sets the viewport box the layout windows against, the headless analog of the
/// measured content box, so frame-windowing can be tested without a DOM.
-fn Viewer::test_set_viewport(
- self : Viewer,
+fn CodeEditorWidget::test_set_viewport(
+ self : CodeEditorWidget,
width~ : Double,
height~ : Double,
) -> Unit {
@@ -153,7 +162,10 @@ fn assert_selection_eq(
///|
/// `assertCursor`: compare the controller's model selection to an expected one.
-fn assert_cursor(viewer : Viewer, expected : @core.Selection) -> Unit raise {
+fn assert_cursor(
+ viewer : CodeEditorWidget,
+ expected : @core.Selection,
+) -> Unit raise {
assert_selection_eq(viewer.test_cursor().get_model_selection(), expected)
}
diff --git a/editor/viewer/test_viewer_wbtest.mbt b/editor/internal/viewer/code_editor_widget/test_viewer_wbtest.mbt
similarity index 94%
rename from editor/viewer/test_viewer_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/test_viewer_wbtest.mbt
index f9bf01f60..f53e50ff2 100644
--- a/editor/viewer/test_viewer_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/test_viewer_wbtest.mbt
@@ -24,9 +24,9 @@ test "set_model populates the view model without attaching" {
/// Step 2 (worked example): the view-axis / soft-wrap projection the memory
/// note flags as previously regressing undetected, now a fast precise harness
/// test instead of a broad/brittle Playwright assertion. Routes a model column
-/// through the full Viewer -> ViewModel -> CoordinatesConverter stack.
+/// through the full CodeEditorWidget -> ViewModel -> CoordinatesConverter stack.
test "soft wrap maps a model column to the correct view position" {
- let options = ViewerOptions(soft_wrap=true)
+ let options = CodeEditorOptions(soft_wrap=true)
with_test_viewer(
"abcdefghij",
viewer => {
@@ -73,7 +73,7 @@ test "render frame windows to the visible range, not the whole document" {
/// DecorationsOverlay's viewport collection), including the
/// `ViewModelDecorations` cache invalidation on decoration change.
test "host delta_decorations flows through query API and render inputs" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
viewer.set_model(Some(test_model("alpha beta\ngamma")))
let view_model = viewer.get_view_model().unwrap()
// Prime the viewport cache before the host adds decorations, so the second
@@ -132,7 +132,7 @@ test "host delta_decorations flows through query API and render inputs" {
/// ownerId) stay invisible to this viewer's scoped queries, while ownerId-0
/// (model-level, e.g. markers) decorations are included.
test "decoration queries are scoped by editor id like Monaco" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
let model = test_model("alpha beta")
viewer.set_model(Some(model))
// Model-level decoration (ownerId 0): visible to the scoped query.
@@ -153,8 +153,8 @@ test "decoration queries are scoped by editor id like Monaco" {
///|
test "editor ids are process-unique and preincrement in construction order" {
- let first = Viewer::Viewer()
- let second = Viewer::Viewer()
+ let first = CodeEditorWidget::CodeEditorWidget()
+ let second = CodeEditorWidget::CodeEditorWidget()
assert_eq(second.editor_id, first.editor_id + 1)
first.dispose()
second.dispose()
@@ -162,7 +162,7 @@ test "editor ids are process-unique and preincrement in construction order" {
///|
test "model change fires before outgoing owner decorations are swept" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
let model = test_model("alpha")
viewer.set_model(Some(model))
let ids = viewer.delta_decorations([], [
@@ -238,7 +238,7 @@ test "initial-build and token-change telemetry read token counts passively" {
FirstCharKeywordToy::{ calls, },
)
defer registration.dispose()
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
defer viewer.dispose()
let model = test_model(
"alpha beta\ngamma delta",
@@ -266,7 +266,7 @@ test "initial-build and token-change telemetry read token counts passively" {
/// Tokenizer (re)registration reaches the ViewModel event spine without
/// re-warming. Passive telemetry reads defaults; explicit demand owns lexing.
test "tokenizer registration stays passive until explicit demand" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
viewer.set_model(
Some(test_model("alpha beta\ngamma delta", language_id="recolor-toy")),
)
@@ -299,7 +299,7 @@ test "tokenizer registration stays passive until explicit demand" {
/// A disposed model detaches itself from the viewer —
/// `model.onWillDispose(() => this.setModel(null))` (`codeEditorWidget.ts:1787`).
test "disposing the installed model detaches it from the viewer" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
let model = test_model("alpha")
viewer.set_model(Some(model))
let mut model_change_events = 0
@@ -315,7 +315,7 @@ test "disposing the installed model detaches it from the viewer" {
/// Swapping models rotates the will-dispose subscription: disposing the
/// replaced model must not detach the current one.
test "disposing a replaced model leaves the current model attached" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
let first = test_model("one")
viewer.set_model(Some(first))
let second = test_model("two")
diff --git a/editor/viewer/view_controller.mbt b/editor/internal/viewer/code_editor_widget/view_controller.mbt
similarity index 96%
rename from editor/viewer/view_controller.mbt
rename to editor/internal/viewer/code_editor_widget/view_controller.mbt
index b06e884ea..3b57de667 100644
--- a/editor/viewer/view_controller.mbt
+++ b/editor/internal/viewer/code_editor_widget/view_controller.mbt
@@ -17,8 +17,8 @@
/// modeled, no bracket-pair/tokenization query surface;
/// * `columnSelection` / alt-drag column select (viewController.ts:297-302)
/// — no column selection.
-fn Viewer::dispatch_mouse(
- self : Viewer,
+fn CodeEditorWidget::dispatch_mouse(
+ self : CodeEditorWidget,
data : @controller.MouseDispatchData,
) -> Unit {
guard self.get_view_model() is Some(view_model) else { return }
@@ -131,8 +131,8 @@ fn has_multicursor_modifier(data : @controller.MouseDispatchData) -> Bool {
/// select the whole document with the source's hard-coded `keyboard` source.
/// The readonly TextModel has no undo stack, so `pushStackElement()` is the
/// documented command-boundary N-A seam.
-fn Viewer::run_select_all_core_command(
- self : Viewer,
+fn CodeEditorWidget::run_select_all_core_command(
+ self : CodeEditorWidget,
nominal_source? : String = "",
) -> Bool {
// ViewController `_selectAll` supplies `{source:'mouse'}` but the core body
@@ -153,8 +153,8 @@ fn Viewer::run_select_all_core_command(
/// Minimal are both only the source command's `None` gate: MoveTo/Word/Line all
/// call `revealAllCursors(..., minimalReveal=true, ScrollType.Smooth)` after
/// passing it. Word/MoveTo reveal horizontally; Line is vertical-only.
-fn Viewer::reveal_mouse_cursor(
- self : Viewer,
+fn CodeEditorWidget::reveal_mouse_cursor(
+ self : CodeEditorWidget,
reveal_type : @controller.NavigationCommandRevealType,
horizontal~ : Bool,
source~ : String,
@@ -172,8 +172,8 @@ fn Viewer::reveal_mouse_cursor(
///|
/// Converts a view position to a model position through the active converter.
-fn Viewer::view_to_model(
- self : Viewer,
+fn CodeEditorWidget::view_to_model(
+ self : CodeEditorWidget,
view_position : @base_common.Position,
) -> @base_common.Position {
match self.get_view_model() {
diff --git a/editor/viewer/view_event_sources.mbt b/editor/internal/viewer/code_editor_widget/view_event_sources.mbt
similarity index 82%
rename from editor/viewer/view_event_sources.mbt
rename to editor/internal/viewer/code_editor_widget/view_event_sources.mbt
index 731b5e997..bd5a9a478 100644
--- a/editor/viewer/view_event_sources.mbt
+++ b/editor/internal/viewer/code_editor_widget/view_event_sources.mbt
@@ -1,8 +1,11 @@
///|
-/// Emits one typed view event at its root Viewer source. A headless Viewer has
+/// Emits one typed view event at its root CodeEditorWidget source. A headless CodeEditorWidget has
/// no browser View (and therefore no ViewPart consumer), so it deliberately
/// performs only the same ViewModel/cache mutations in that mode.
-fn Viewer::emit_view_event(self : Viewer, event : @view.ViewEvent) -> Unit {
+fn CodeEditorWidget::emit_view_event(
+ self : CodeEditorWidget,
+ event : @view.ViewEvent,
+) -> Unit {
match self.current_code_view() {
Some(view) => view.emit_view_event(event)
None => ()
@@ -12,8 +15,10 @@ fn Viewer::emit_view_event(self : Viewer, event : @view.ViewEvent) -> Unit {
///|
/// Opens a source-shaped nested event batch on the current browser View. The
/// returned View is the exact dispatcher whose matching `end` must run even
-/// if Viewer state changes before the batch completes.
-fn Viewer::begin_view_event_batch(self : Viewer) -> @view.View? {
+/// if CodeEditorWidget state changes before the batch completes.
+fn CodeEditorWidget::begin_view_event_batch(
+ self : CodeEditorWidget,
+) -> @view.View? {
match self.current_code_view() {
Some(view) => {
view.begin_view_event_batch() |> ignore
@@ -50,8 +55,8 @@ fn view_tokens_changed_event(
///|
/// Emits the committed view-space cursor state before the public outgoing
/// cursor pair, as in Monaco.
-fn Viewer::emit_view_cursor_state_changed(
- self : Viewer,
+fn CodeEditorWidget::emit_view_cursor_state_changed(
+ self : CodeEditorWidget,
view_model : @view_model.ViewModel,
) -> Unit {
self.emit_view_event(
@@ -70,8 +75,8 @@ fn view_cursor_state_changed_event(
/// Emits a mapping-only cursor reprojection only when the view-coordinate
/// selection actually changed. The model selection remains stable and the
/// source reason is `NotSet`.
-fn Viewer::emit_view_cursor_reprojection(
- self : Viewer,
+fn CodeEditorWidget::emit_view_cursor_reprojection(
+ self : CodeEditorWidget,
view_model : @view_model.ViewModel,
previous_view_selection : @core.Selection,
) -> Unit {
@@ -89,8 +94,8 @@ fn Viewer::emit_view_cursor_reprojection(
///|
/// Preserves the width needed by ViewLines and all four independent changed
/// flags from ViewLayout's source event.
-fn Viewer::emit_view_scroll_changed(
- self : Viewer,
+fn CodeEditorWidget::emit_view_scroll_changed(
+ self : CodeEditorWidget,
change : @view_layout.ScrollChange,
) -> Unit {
self.emit_view_event(ViewScrollChanged(view_scroll_changed_event(change)))
diff --git a/editor/viewer/view_event_sources_wbtest.mbt b/editor/internal/viewer/code_editor_widget/view_event_sources_wbtest.mbt
similarity index 95%
rename from editor/viewer/view_event_sources_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/view_event_sources_wbtest.mbt
index 0df029b87..69e49de16 100644
--- a/editor/viewer/view_event_sources_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/view_event_sources_wbtest.mbt
@@ -1,5 +1,5 @@
///|
-fn estimated_font_info(options : ViewerOptions) -> @config.FontInfo {
+fn estimated_font_info(options : CodeEditorOptions) -> @config.FontInfo {
@config.FontInfo::estimated(options.bare_font_info(pixel_ratio=1.0))
}
@@ -7,8 +7,8 @@ fn estimated_font_info(options : ViewerOptions) -> @config.FontInfo {
/// Builds the two immutable owner snapshots while retaining the original
/// branch tests' explicit LayoutInfo comparison input.
fn test_configuration_changed_event(
- previous_options : ViewerOptions,
- current_options : ViewerOptions,
+ previous_options : CodeEditorOptions,
+ current_options : CodeEditorOptions,
previous_font_info : @config.FontInfo,
current_font_info : @config.FontInfo,
previous_soft_wrap_column : Int,
@@ -90,7 +90,7 @@ extern "js" fn test_view_cursor_selections(
///|
test "view configuration source preserves independent paint axes and no-op" {
- let previous = ViewerOptions::default()
+ let previous = CodeEditorOptions::default()
let font_info = estimated_font_info(previous)
assert_true(
test_configuration_changed_event(
@@ -151,7 +151,7 @@ test "view configuration source preserves independent paint axes and no-op" {
///|
test "update_options complete-snapshot no-op skips layout invalidation" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
assert_false(viewer.configuration.measure_requested)
viewer.update_options(viewer.get_options())
assert_false(viewer.configuration.measure_requested)
@@ -160,7 +160,7 @@ test "update_options complete-snapshot no-op skips layout invalidation" {
///|
test "view configuration source separates layout font line-height and wrapping" {
- let previous = ViewerOptions::default()
+ let previous = CodeEditorOptions::default()
let font_info = estimated_font_info(previous)
let letter_spaced = {
..font_info,
@@ -216,7 +216,7 @@ test "decimal digit bands and compensated layout lanes stay independently visibl
assert_eq(digit_count(99), 2)
assert_eq(digit_count(100), 3)
- let initial_options = ViewerOptions(
+ let initial_options = CodeEditorOptions(
line_numbers_min_chars=5,
line_decorations_width=10,
folding=false,
@@ -228,7 +228,7 @@ test "decimal digit bands and compensated layout lanes stay independently visibl
})
let before = configuration.snapshot().layout_identity(3, 3)
configuration.replace_options(
- ViewerOptions(
+ CodeEditorOptions(
line_numbers_min_chars=4,
line_decorations_width=20,
folding=false,
@@ -244,8 +244,8 @@ test "decimal digit bands and compensated layout lanes stay independently visibl
///|
test "set_value synchronizes a decimal line-count band into layout identity" {
- let viewer = Viewer::Viewer(
- options=ViewerOptions(line_numbers_min_chars=1, folding=false),
+ let viewer = CodeEditorWidget::CodeEditorWidget(
+ options=CodeEditorOptions(line_numbers_min_chars=1, folding=false),
)
let nine = @model.TextModel(
try! @base_common.Uri::from(
@@ -302,7 +302,7 @@ test "wrapped view-line growth does not widen the model line-number band" {
snapshot.line_numbers_width(configuration.model_line_count),
)
},
- options=ViewerOptions(
+ options=CodeEditorOptions(
soft_wrap=true,
line_numbers_min_chars=1,
folding=false,
@@ -413,7 +413,7 @@ test "validation option rotates the headless decoration provider" {
///|
test "view-zone callback is skipped without a real model view" {
- let viewer = Viewer::Viewer()
+ let viewer = CodeEditorWidget::CodeEditorWidget()
let called = Ref(false)
viewer.change_view_zones(_ => called.val = true)
assert_false(viewer.configuration.measure_requested)
diff --git a/editor/viewer/view_host.mbt b/editor/internal/viewer/code_editor_widget/view_host.mbt
similarity index 72%
rename from editor/viewer/view_host.mbt
rename to editor/internal/viewer/code_editor_widget/view_host.mbt
index 5339f7e4a..cc8e8f766 100644
--- a/editor/viewer/view_host.mbt
+++ b/editor/internal/viewer/code_editor_widget/view_host.mbt
@@ -1,13 +1,13 @@
// The viewer core's side of the `internal/viewer/browser/view` seam: builds the
// `ViewContext` capability struct `View::render` needs (the local analog of
// Monaco's `View` reading its owning `CodeEditorWidget`) and owns the render
-// loop entry points. It stays beside the root-owned Viewer state so the
+// loop entry points. It stays beside the root-owned CodeEditorWidget state so the
// internal view package does not import the public facade and create a
// `internal/viewer/browser/view ↔ viewer` dependency cycle.
///|
-fn Viewer::build_view_context(
- self : Viewer,
+fn CodeEditorWidget::build_view_context(
+ self : CodeEditorWidget,
view : @view.View,
) -> @view.ViewContext {
{
@@ -39,8 +39,8 @@ fn Viewer::build_view_context(
/// closure. The keys resolve through the command registry
/// (`hoverActions.ts`'s scroll cluster registered in
/// `hover_contribution.mbt`, gated on `hoverFocused`/`hoverVisible`).
-fn Viewer::handle_hover_keydown(
- self : Viewer,
+fn CodeEditorWidget::handle_hover_keydown(
+ self : CodeEditorWidget,
event : @rdom.Event,
keyboard : @rdom.KeyboardEvent,
) -> Unit {
@@ -81,7 +81,7 @@ fn Viewer::handle_hover_keydown(
/// F->>O: publish coalesced scroll event
/// end
/// ```
-fn Viewer::flush_render(self : Viewer) -> Unit {
+fn CodeEditorWidget::flush_render(self : CodeEditorWidget) -> Unit {
if self.disposed {
return
}
@@ -92,18 +92,12 @@ fn Viewer::flush_render(self : Viewer) -> Unit {
self.measure()
}
guard self.model_slot.current is Some(model_data) else {
- // No model → no view; the Viewer-owned placeholder is the whole paint.
+ // No model → no view; the CodeEditorWidget-owned placeholder is the whole paint.
self.render_placeholder()
+ self.publish_render_complete()
return
}
- guard model_data.browser is Some(presentation) else { return }
- let browser = match presentation {
- Code(code) => code
- Markdown(markdown) => {
- self.flush_markdown_document_render(model_data, markdown.view)
- return
- }
- }
+ guard model_data.browser is Some(browser) else { return }
let view = browser.view
let view_model = model_data.view_model
// Write phase.
@@ -189,73 +183,66 @@ fn Viewer::flush_render(self : Viewer) -> Unit {
self.last_scroll_event = Some(event)
self.did_scroll.fire(event)
}
+ self.publish_render_complete()
}
///|
-/// Markdown HTML is replaced synchronously at the model/theme boundary. The
-/// scheduled flush publishes that installed generation and coalesces native
-/// scroll/extent changes through the same public event as Code.
-fn Viewer::flush_markdown_document_render(
- self : Viewer,
- model_data : ModelData,
- markdown : @markdown_document.MarkdownDocumentView,
-) -> Unit {
- let started = @base_browser.monotonic_now_ms()
- let scroll_top = markdown.scroll_top()
- let scroll_left = markdown.scroll_left()
- self.emit_scroll_frame_phase(RenderStarted, scroll_top, scroll_left)
- self.emit_scroll_frame_phase(RenderFinished, scroll_top, scroll_left)
- match self.pending_rendered {
- Some(pending) => {
- self.pending_rendered = None
- self.emit_model_rendered({
- model: pending.model,
- line_count: model_data.model.snapshot.line_count(),
- rendered_lines: markdown.projection().block_anchors.length(),
- patch_ms: @base_browser.monotonic_now_ms() - started,
- fresh: pending.fresh,
- })
- }
- None => ()
+/// Publishes one completed DOM paint after all geometry and coalesced scroll
+/// facts from that paint have committed. DiffEditor uses this seam instead of
+/// inferring paint completion from optional scroll-extent changes.
+fn CodeEditorWidget::publish_render_complete(self : CodeEditorWidget) -> Unit {
+ if self.disposed {
+ return
}
- if self.scroll_dirty {
- self.scroll_dirty = false
- let event : @editor_api.ScrollEvent = {
- scroll_top,
- scroll_left,
- scroll_width: markdown.scroll_width(),
- scroll_height: markdown.scroll_height(),
- scroll_top_changed: true,
- scroll_left_changed: true,
- scroll_width_changed: true,
- scroll_height_changed: true,
+ self.render_generation += 1
+ self.did_render.fire(self.render_generation)
+}
+
+///|
+/// Runs `callback` once after a render newer than the call site has completed.
+/// The returned handle cancels the waiter. The captured generation prevents a
+/// render already in progress (or a late callback retained by a former diff
+/// layout transaction) from crossing the fence.
+pub fn CodeEditorWidget::after_next_render(
+ self : CodeEditorWidget,
+ callback : () -> Unit,
+) -> @base_common.Disposable {
+ if self.disposed {
+ return @base_common.Disposable::from(() => ())
+ }
+ let target_generation = self.render_generation + 1
+ let registration : Ref[@base_common.Disposable?] = Ref(None)
+ let listener = self.did_render.event(generation => {
+ if generation < target_generation {
+ return
}
- let event = match self.last_scroll_event {
- Some(previous) =>
- {
- ..event,
- scroll_top_changed: event.scroll_top != previous.scroll_top,
- scroll_left_changed: event.scroll_left != previous.scroll_left,
- scroll_width_changed: event.scroll_width != previous.scroll_width,
- scroll_height_changed: event.scroll_height != previous.scroll_height,
- }
- None => event
+ match registration.val {
+ Some(disposable) => {
+ registration.val = None
+ disposable.dispose()
+ }
+ None => return
}
- if event.scroll_top_changed ||
- event.scroll_left_changed ||
- event.scroll_width_changed ||
- event.scroll_height_changed {
- self.last_scroll_event = Some(event)
- self.did_scroll.fire(event)
+ callback()
+ })
+ registration.val = Some(listener)
+ self.schedule_render()
+ @base_common.Disposable::from(() => {
+ match registration.val {
+ Some(disposable) => {
+ registration.val = None
+ disposable.dispose()
+ }
+ None => ()
}
- }
+ })
}
///|
-/// Repaints the Viewer-owned no-model placeholder (the `flush_render`
+/// Repaints the CodeEditorWidget-owned no-model placeholder (the `flush_render`
/// no-model arm): the empty-state classes plus the `placeholder` option's
/// message text.
-fn Viewer::render_placeholder(self : Viewer) -> Unit {
+fn CodeEditorWidget::render_placeholder(self : CodeEditorWidget) -> Unit {
let options = self.configuration.snapshot().options
match self.mount {
Headless => ()
@@ -271,7 +258,7 @@ fn Viewer::render_placeholder(self : Viewer) -> Unit {
}
///|
-fn Viewer::schedule_render(self : Viewer) -> Unit {
+fn CodeEditorWidget::schedule_render(self : CodeEditorWidget) -> Unit {
// Headless (never attached) → no rAF loop; with a container the flush
// paints either the per-model view or the no-model placeholder.
if self.disposed {
diff --git a/editor/viewer/view_zones_host.mbt b/editor/internal/viewer/code_editor_widget/view_zones_host.mbt
similarity index 89%
rename from editor/viewer/view_zones_host.mbt
rename to editor/internal/viewer/code_editor_widget/view_zones_host.mbt
index fdceb3234..ccd07427a 100644
--- a/editor/viewer/view_zones_host.mbt
+++ b/editor/internal/viewer/code_editor_widget/view_zones_host.mbt
@@ -1,4 +1,4 @@
-// The Viewer-owned public/ordering seam for the per-model browser ViewZones
+// The CodeEditorWidget-owned public/ordering seam for the per-model browser ViewZones
// part. Registration, DOM, and whitespace mutation live on `View.view_zones`;
// this root method owns the CodeEditorWidget guard, internal-before-outgoing
// delivery, and unconditional render schedule.
@@ -21,6 +21,7 @@ pub fn view_zone(
min_width_in_px? : Double,
margin_dom_node? : @rdom.Element,
on_dom_node_top? : (Double) -> Unit raise,
+ on_dom_node_layout? : (Bool, Double) -> Unit raise,
on_computed_height? : (Double) -> Unit raise,
) -> @editor_browser.ViewZone {
ViewZone(
@@ -37,6 +38,7 @@ pub fn view_zone(
min_width_in_px?,
margin_dom_node?,
on_dom_node_top?,
+ on_dom_node_layout?,
on_computed_height?,
)
}
@@ -47,8 +49,8 @@ pub fn view_zone(
/// contained by the ViewZones part; afterward this method always schedules one
/// render, while actual whitespace changes first finish the internal ViewEvent
/// and then enter the ViewModel outgoing queue/public event.
-pub fn Viewer::change_view_zones(
- self : Viewer,
+pub fn CodeEditorWidget::change_view_zones(
+ self : CodeEditorWidget,
update : (@editor_browser.ViewZoneChangeAccessor) -> Unit raise,
) -> Unit {
if self.disposed {
diff --git a/editor/viewer/view_zones_host_wbtest.mbt b/editor/internal/viewer/code_editor_widget/view_zones_host_wbtest.mbt
similarity index 100%
rename from editor/viewer/view_zones_host_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/view_zones_host_wbtest.mbt
diff --git a/editor/viewer/viewer.mbt b/editor/internal/viewer/code_editor_widget/viewer.mbt
similarity index 80%
rename from editor/viewer/viewer.mbt
rename to editor/internal/viewer/code_editor_widget/viewer.mbt
index 2a7e4dac8..41ca0d7c0 100644
--- a/editor/viewer/viewer.mbt
+++ b/editor/internal/viewer/code_editor_widget/viewer.mbt
@@ -10,28 +10,21 @@
/// All methods are plain calls (the host wraps them in its own effect
/// system if it has one). The host must never render its own children
/// into the element passed to `attach`.
-struct Viewer {
- services : ViewerServices
- /// Diff-editor panes always use the code presentation even for `.md`
- /// resources, matching Monaco's two CodeEditorWidget composition. Ordinary
- /// public Viewers keep automatic Markdown-document routing.
- force_code_presentation : Bool
- /// Private embedding role. A Peek preview borrows the same public services
- /// but cannot recursively create another Peek widget.
- role : ViewerRole
+struct CodeEditorWidget {
+ services : CodeEditorServices
/// Services created by an omitted constructor argument belong to this
- /// Viewer. An explicitly supplied bundle remains caller-owned and may be
+ /// CodeEditorWidget. An explicitly supplied bundle remains caller-owned and may be
/// shared by other Viewers.
owns_services : Bool
mut disposed : Bool
- /// Viewer-id registration in the lower JS-only testing registry. It is
+ /// CodeEditorWidget-id registration in the lower JS-only testing registry. It is
/// generation-safe and owns all workbench/browser-test observations and
- /// private controls for this Viewer.
+ /// private controls for this CodeEditorWidget.
mut testing_registration : @base_common.Disposable?
- /// Viewer-lifetime subscriptions, distinct from ModelData's swap-scoped
+ /// CodeEditorWidget-lifetime subscriptions, distinct from ModelData's swap-scoped
/// listeners and View's browser-resource store.
lifetime_disposables : Array[@base_common.Disposable]
- /// Viewer-lifetime options, measured geometry, font facts, and derived
+ /// CodeEditorWidget-lifetime options, measured geometry, font facts, and derived
/// layout identity behind one configuration transition owner.
configuration : EditorConfigurationState
did_change_model : @base_common.Emitter[@editor_api.ModelChangedEvent]
@@ -82,8 +75,8 @@ struct Viewer {
/// The last fired scroll event's facts, the diff base for the next event's
/// `*_changed` flags (Monaco's `ScrollEvent` carries per-axis change bits).
mut last_scroll_event : @editor_api.ScrollEvent?
- /// Viewer-lifetime generation/current-model owner. Browser-only state lives
- /// under the current ModelData rather than beside it on the root Viewer.
+ /// CodeEditorWidget-lifetime generation/current-model owner. Browser-only state lives
+ /// under the current ModelData rather than beside it on the root CodeEditorWidget.
model_slot : ViewerModelSlot
/// One-way browser attachment aggregate. It owns the caller host, the
/// atomic no-model placeholder, render publication, and DOM focus state.
@@ -91,6 +84,15 @@ struct Viewer {
/// Registered overlay widgets by id (Monaco's `_overlayWidgets`), re-added
/// to every per-model view (`cew:1901-1905`).
overlay_widgets : Map[String, @editor_browser.OverlayWidget]
+ /// Kernel-owned overview rulers. The handles survive model swaps, reattach
+ /// to the new per-model View, and are disposed before the editor tears that
+ /// View down. Higher-level coordinators only submit immutable entries.
+ overview_rulers : Array[CodeEditorOverviewRuler]
+ /// Monotonic paint generation used by the internal `after_next_render`
+ /// primitive. A waiter captures `render_generation + 1`, so a callback from
+ /// an older layout transaction can never satisfy a newer render fence.
+ mut render_generation : Int
+ did_render : @base_common.Emitter[Int]
mut pending_rendered : PendingRendered?
mut scroll_dirty : Bool
/// This editor's decoration `ownerId` (Monaco's per-widget editor id): the
@@ -102,12 +104,6 @@ struct Viewer {
contributions : EditorContributions
}
-///|
-priv enum ViewerRole {
- OuterViewer
- PeekPreviewViewer
-} derive(Eq)
-
///|
priv struct PendingRendered {
model : @model.TextModel
@@ -136,7 +132,7 @@ priv struct HorizontalRevealRangeRequest {
}
///|
-struct ViewerViewState {
+struct CodeEditorViewState {
scroll_top : Double
scroll_left : Double
} derive(Debug)
@@ -146,16 +142,15 @@ struct ViewerViewState {
/// internally; an explicit bundle is borrowed and may be shared. Call
/// `attach` before setting documents so the first render has a DOM view to
/// measure and patch.
-fn Viewer::Viewer(
- services? : ViewerServices,
- options? : ViewerOptions = ViewerOptions::default(),
+fn CodeEditorWidget::CodeEditorWidget(
+ services? : CodeEditorServices,
+ options? : CodeEditorOptions = CodeEditorOptions::default(),
initial_container? : @rdom.Element,
- role? : ViewerRole = OuterViewer,
- force_code_presentation? : Bool = false,
-) -> Viewer {
+ contributions? : CodeEditorContributionDescriptors = CodeEditorContributionDescriptors::standalone(),
+) -> CodeEditorWidget {
let (services, owns_services) = match services {
Some(services) => (services, false)
- None => (ViewerServices(), true)
+ None => (CodeEditorServices(), true)
}
let font_info = @config.FontInfo::estimated(
options.bare_font_info(pixel_ratio=@base_browser.device_pixel_ratio()),
@@ -164,10 +159,8 @@ fn Viewer::Viewer(
Some(container) => read_container_size(container)
None => (0.0, 0.0)
}
- let viewer : Viewer = {
+ let viewer : CodeEditorWidget = {
services,
- force_code_presentation,
- role,
owns_services,
disposed: false,
testing_registration: None,
@@ -198,6 +191,9 @@ fn Viewer::Viewer(
model_slot: ViewerModelSlot(),
mount: Headless,
overlay_widgets: Map([]),
+ overview_rulers: [],
+ render_generation: 0,
+ did_render: Emitter(),
pending_rendered: None,
scroll_dirty: false,
editor_id: next_editor_id(),
@@ -218,7 +214,7 @@ fn Viewer::Viewer(
// Editor contributions instantiate through the registry
// (`codeEditorWidget.ts:344` `this._contributions.initialize(...)`);
// they live until `dispose` — `set_model` does not re-create them.
- editor_registry().instantiate_all(viewer)
+ editor_registry().instantiate_enabled(viewer, contributions)
viewer.testing_registration = Some(
@viewer_testing.register_viewer(viewer.get_id(), set_hidden_areas=(
ranges,
@@ -232,8 +228,8 @@ fn Viewer::Viewer(
///|
/// Package-private scheduler seam for deterministic hover timing tests. The
/// production constructor always installs the clearable browser timeout.
-fn Viewer::set_hover_host_for_test(
- self : Viewer,
+fn CodeEditorWidget::set_hover_host_for_test(
+ self : CodeEditorWidget,
schedule : (() -> Unit, Int) -> @base_common.Disposable,
test_launch_async_task : (async () -> Unit noraise) -> Unit,
) -> Unit {
@@ -250,12 +246,15 @@ fn Viewer::set_hover_host_for_test(
/// The test seam is intentionally package-private and therefore only called
/// by a white-box test build; retain it in the ordinary library build too.
let _hover_scheduler_test_seam_anchor : Unit = ignore(
- (Viewer::set_hover_host_for_test, Viewer::start_showing_hover_at_range),
+ (
+ CodeEditorWidget::set_hover_host_for_test,
+ CodeEditorWidget::start_showing_hover_at_range,
+ ),
)
///|
-fn Viewer::on_did_detach_model_for_diagnostics(
- self : Viewer,
+fn CodeEditorWidget::on_did_detach_model_for_diagnostics(
+ self : CodeEditorWidget,
listener : () -> Unit,
) -> @base_common.Disposable {
self.did_detach_model_for_diagnostics.event(_ => listener())
@@ -263,14 +262,14 @@ fn Viewer::on_did_detach_model_for_diagnostics(
///|
let _model_detach_diagnostics_test_seam_anchor : Unit = ignore(
- Viewer::on_did_detach_model_for_diagnostics,
+ CodeEditorWidget::on_did_detach_model_for_diagnostics,
)
///|
-/// Retains a Viewer-lifetime handle, disposing immediately when registration
-/// races with or follows Viewer disposal.
-fn Viewer::add_lifetime_disposable(
- self : Viewer,
+/// Retains a CodeEditorWidget-lifetime handle, disposing immediately when registration
+/// races with or follows CodeEditorWidget disposal.
+fn CodeEditorWidget::add_lifetime_disposable(
+ self : CodeEditorWidget,
disposable : @base_common.Disposable,
) -> Unit {
if self.disposed {
@@ -281,7 +280,9 @@ fn Viewer::add_lifetime_disposable(
}
///|
-fn Viewer::dispose_lifetime_disposables(self : Viewer) -> Unit {
+fn CodeEditorWidget::dispose_lifetime_disposables(
+ self : CodeEditorWidget,
+) -> Unit {
for disposable in self.lifetime_disposables {
disposable.dispose()
}
@@ -289,7 +290,7 @@ fn Viewer::dispose_lifetime_disposables(self : Viewer) -> Unit {
}
///|
-fn Viewer::cancel_render_frame(self : Viewer) -> Unit {
+fn CodeEditorWidget::cancel_render_frame(self : CodeEditorWidget) -> Unit {
match self.mount {
Headless => ()
Mounted(mounted) => mounted.cancel_render_frame()
@@ -313,16 +314,22 @@ fn next_editor_id() -> Int {
///|
/// Creates and attaches a viewer in one call — the `monaco.editor.create`
-/// entry point. Service ownership follows `Viewer::Viewer`.
-pub fn Viewer::create(
+/// entry point. Service ownership follows `CodeEditorWidget::CodeEditorWidget`.
+pub fn CodeEditorWidget::create(
host : @rdom.Element,
- services? : ViewerServices,
- options? : ViewerOptions = ViewerOptions::default(),
-) -> Viewer {
+ services? : CodeEditorServices,
+ options? : CodeEditorOptions = CodeEditorOptions::default(),
+ contributions~ : CodeEditorContributionDescriptors,
+) -> CodeEditorWidget {
// `EditorConfiguration` constructs its `ElementSizeObserver` before model
// attachment in Monaco. Seed the same client-box fact before contributions
- // can observe this Viewer; `attach` rechecks after publishing the mount.
- let viewer = Viewer(services?, options~, initial_container=host)
+ // can observe this CodeEditorWidget; `attach` rechecks after publishing the mount.
+ let viewer = CodeEditorWidget(
+ services?,
+ options~,
+ initial_container=host,
+ contributions~,
+ )
viewer.attach(host)
viewer
}
@@ -330,8 +337,8 @@ pub fn Viewer::create(
///|
/// Fires when the installed model changed — Monaco's `onDidChangeModel`
/// carrying `IModelChangedEvent` (the old/new resource URIs).
-pub fn Viewer::on_did_change_model(
- self : Viewer,
+pub fn CodeEditorWidget::on_did_change_model(
+ self : CodeEditorWidget,
listener : (@editor_api.ModelChangedEvent) -> Unit,
) -> @base_common.Disposable {
self.did_change_model.event(listener)
@@ -342,8 +349,8 @@ pub fn Viewer::on_did_change_model(
/// `onDidChangeModelContent` (`cew:105`), carrying the model's public
/// `IModelContentChangedEvent` payload. Today's only producer is the
/// `set_value` flush lane, so `is_flush` is always true.
-pub fn Viewer::on_did_change_model_content(
- self : Viewer,
+pub fn CodeEditorWidget::on_did_change_model_content(
+ self : CodeEditorWidget,
listener : (@model.ModelContentChangedEvent) -> Unit,
) -> @base_common.Disposable {
self.did_change_model_content.event(listener)
@@ -353,8 +360,8 @@ pub fn Viewer::on_did_change_model_content(
/// Fires when the primary cursor's model position changed — Monaco's
/// `onDidChangeCursorPosition` (`cew:117-118`, fired at `:1836-1845`).
/// Single-cursor: `secondary_positions` is always empty.
-pub fn Viewer::on_did_change_cursor_position(
- self : Viewer,
+pub fn CodeEditorWidget::on_did_change_cursor_position(
+ self : CodeEditorWidget,
listener : (@editor_api.CursorPositionChangedEvent) -> Unit,
) -> @base_common.Disposable {
self.did_change_cursor_position.event(listener)
@@ -364,8 +371,8 @@ pub fn Viewer::on_did_change_cursor_position(
/// Fires when the primary cursor's model selection changed — Monaco's
/// `onDidChangeCursorSelection` (`cew:120-121`, fired at `:1846-1857`).
/// Single-cursor: the secondary arrays are always empty.
-pub fn Viewer::on_did_change_cursor_selection(
- self : Viewer,
+pub fn CodeEditorWidget::on_did_change_cursor_selection(
+ self : CodeEditorWidget,
listener : (@editor_api.CursorSelectionChangedEvent) -> Unit,
) -> @base_common.Disposable {
self.did_change_cursor_selection.event(listener)
@@ -374,8 +381,8 @@ pub fn Viewer::on_did_change_cursor_selection(
///|
/// Fires after the editor scroll position or extents changed — Monaco's
/// `onDidScrollChange`, coalesced onto the render flush.
-pub fn Viewer::on_did_scroll_change(
- self : Viewer,
+pub fn CodeEditorWidget::on_did_scroll_change(
+ self : CodeEditorWidget,
listener : (@editor_api.ScrollEvent) -> Unit,
) -> @base_common.Disposable {
self.did_scroll.event(listener)
@@ -385,16 +392,16 @@ pub fn Viewer::on_did_scroll_change(
/// Fires after a successful ViewZones whitespace transaction. Internal View
/// delivery has completed before the ViewModel outgoing arm reaches this
/// emitter.
-pub fn Viewer::on_did_change_view_zones(
- self : Viewer,
+pub fn CodeEditorWidget::on_did_change_view_zones(
+ self : CodeEditorWidget,
listener : () -> Unit,
) -> @base_common.Disposable {
self.did_change_view_zones.event(_ => listener())
}
///|
-pub fn Viewer::on_did_dispose(
- self : Viewer,
+pub fn CodeEditorWidget::on_did_dispose(
+ self : CodeEditorWidget,
listener : () -> Unit,
) -> @base_common.Disposable {
self.did_dispose.event(_ => listener())
@@ -404,7 +411,10 @@ pub fn Viewer::on_did_dispose(
/// Applies runtime options — `updateOptions`. Theme and placeholder apply
/// without a rebuild; the current view model is rebuilt when wrapping, tab, or
/// render settings change.
-pub fn Viewer::update_options(self : Viewer, options : ViewerOptions) -> Unit {
+pub fn CodeEditorWidget::update_options(
+ self : CodeEditorWidget,
+ options : CodeEditorOptions,
+) -> Unit {
let previous_configuration = self.configuration.event_snapshot()
let previous = previous_configuration.configuration.options
if previous == options {
@@ -427,13 +437,6 @@ pub fn Viewer::update_options(self : Viewer, options : ViewerOptions) -> Unit {
self.apply_theme()
self.markdown_comments_on_theme_changed(options.theme)
}
- if previous.show_unused != options.show_unused {
- match self.current_markdown_document() {
- Some(markdown) =>
- markdown.apply_marker_unused_visibility(options.show_unused)
- None => ()
- }
- }
if previous.smooth_scrolling != options.smooth_scrolling {
match self.get_view_model() {
Some(view_model) =>
@@ -470,19 +473,20 @@ pub fn Viewer::update_options(self : Viewer, options : ViewerOptions) -> Unit {
None => ()
}
end_view_event_batch(event_batch)
+ self.refresh_managed_overview_rulers()
self.schedule_render()
}
///|
/// Mirrors the theme option onto the active presentation without replacing its
/// root or scroll state.
-fn Viewer::apply_theme(self : Viewer) -> Unit {
+fn CodeEditorWidget::apply_theme(self : CodeEditorWidget) -> Unit {
let theme = self.configuration.snapshot().options.theme
match self.mount {
Headless => ()
Mounted(mounted) => mounted.apply_placeholder_theme(theme)
}
- match self.current_browser_presentation() {
+ match self.current_code_browser_data() {
Some(presentation) => presentation.apply_theme(theme)
None => ()
}
@@ -490,11 +494,14 @@ fn Viewer::apply_theme(self : Viewer) -> Unit {
///|
/// Takes ownership of the host element: stores the container and mounts the
-/// Viewer-owned no-model placeholder. The per-model `View` is created by
+/// CodeEditorWidget-owned no-model placeholder. The per-model `View` is created by
/// `attach_model` and destroyed by `detach_model` (Monaco's `_attachModel`
/// → `_createView` / `_detachModel`). The host element must stay mounted
/// and must never receive host-rendered children.
-fn Viewer::attach(self : Viewer, host : @rdom.Element) -> Unit {
+fn CodeEditorWidget::attach(
+ self : CodeEditorWidget,
+ host : @rdom.Element,
+) -> Unit {
if self.disposed {
return
}
@@ -508,14 +515,14 @@ fn Viewer::attach(self : Viewer, host : @rdom.Element) -> Unit {
let placeholder = PlaceholderDom(self.configuration.snapshot().options.theme)
host.append_child(placeholder.root.as_node())
self.mount = Mounted(MountedViewer(host, placeholder))
- // Public construction is synchronous: the returned Viewer already reports
+ // Public construction is synchronous: the returned CodeEditorWidget already reports
// the host client box even when it has no model and before any animation
// frame. The constructor supplied the same fact before contributions; this
// second read also covers the private headless-then-attach test path.
self.measure()
// Untrusted readings re-measure after 5s (`FontMeasurements.onDidChange`,
// e.g. once a web font finished loading) — Monaco's
- // `_recomputeOptions` subscription in `editorConfiguration.ts`. Viewer-
+ // `_recomputeOptions` subscription in `editorConfiguration.ts`. CodeEditorWidget-
// level: it outlives any per-model view.
self.add_lifetime_disposable(
@browser_config.font_measurements().on_did_change(() => {
@@ -537,7 +544,10 @@ fn Viewer::attach(self : Viewer, host : @rdom.Element) -> Unit {
/// content-change event. No model → no-op, like the source's `_modelData`
/// guard. The `_beginUpdate`/`_endUpdate` bracket is N-A — the viewer's
/// repaint already coalesces on the scheduled render flush.
-pub fn Viewer::set_value(self : Viewer, value : String) -> Unit {
+pub fn CodeEditorWidget::set_value(
+ self : CodeEditorWidget,
+ value : String,
+) -> Unit {
if self.disposed {
return
}
@@ -551,7 +561,10 @@ pub fn Viewer::set_value(self : Viewer, value : String) -> Unit {
/// current model and clears the rendered content. Like Monaco, swapping the
/// model resets the scroll position and controller state; hosts preserve or
/// restore scroll through `save_view_state`/`restore_view_state`.
-pub fn Viewer::set_model(self : Viewer, model : @model.TextModel?) -> Unit {
+pub fn CodeEditorWidget::set_model(
+ self : CodeEditorWidget,
+ model : @model.TextModel?,
+) -> Unit {
if self.disposed {
return
}
@@ -616,7 +629,7 @@ pub fn Viewer::set_model(self : Viewer, model : @model.TextModel?) -> Unit {
/// also lets the model cancel a pending unstable-range debounce. A token
/// listener may synchronously replace the model, but this call retains and
/// completes only the model that was current when it began.
-pub fn Viewer::handle_initialized(self : Viewer) -> Unit {
+pub fn CodeEditorWidget::handle_initialized(self : CodeEditorWidget) -> Unit {
if self.disposed {
return
}
@@ -629,7 +642,7 @@ pub fn Viewer::handle_initialized(self : Viewer) -> Unit {
/// state, dispose the bundle's listeners, remove the view's DOM subtree from
/// the container (restoring the no-model placeholder), drop `data-mode-id`,
/// and hand back the outgoing model for the owner-id decoration sweep.
-fn Viewer::detach_model(self : Viewer) -> @model.TextModel? {
+fn CodeEditorWidget::detach_model(self : CodeEditorWidget) -> @model.TextModel? {
guard self.model_slot.current is Some(_) else { return None }
self.close_definition_peek(restore_focus=false)
guard self.model_slot.invalidate_current_for_detach() is Some(model_data) else {
@@ -667,53 +680,20 @@ fn Viewer::detach_model(self : Viewer) -> @model.TextModel? {
None => ()
}
self.clear_agent_feedback_widgets() |> ignore
- // Markdown ViewZones retain caller DOM below the outgoing View. Release the
- // shared renderer/observer lifetimes and remove their hidden source before
- // the browser presentation is retired.
+ // Markdown-comment ViewZones retain caller DOM below the outgoing code View.
self.clear_markdown_comment_model_state()
- match model_data.browser {
- Some(Markdown(markdown)) => {
- let root = markdown.view.root()
- // Markdown feature requests/listeners and shared renderers are retired
- // while their DOM is still mounted. The inert root is removed next; only
- // then may the remaining attachment publish its final 1 -> 0 transition.
- model_data.dispose_markdown_presentation()
- match self.mount {
- Mounted(mounted) => {
- mounted.container.remove_attribute("data-mode-id")
- mounted.remove_presentation_root(
- root,
- restore_placeholder=!self.disposed,
- )
- }
- Headless => ()
- }
- model_data.dispose_after_markdown_root(() => {
- self.did_detach_model_for_diagnostics.fire(())
- })
+ let root = model_data.browser.map(browser => browser.view.root)
+ // Keep Monaco's Code order: listener -> attached-view -> View -> ViewModel,
+ // followed by root removal.
+ model_data.dispose(() => self.did_detach_model_for_diagnostics.fire(()))
+ match (self.mount, root) {
+ (Mounted(mounted), Some(root)) => {
+ mounted.container.remove_attribute("data-mode-id")
+ mounted.remove_presentation_root(root, restore_placeholder=!self.disposed)
}
- Some(Code(code)) => {
- let root = code.view.root
- // Keep the pre-existing Code order: listener -> attached-view -> View ->
- // ViewModel, followed by root removal.
- model_data.dispose_code_or_headless(() => {
- self.did_detach_model_for_diagnostics.fire(())
- })
- match self.mount {
- Mounted(mounted) => {
- mounted.container.remove_attribute("data-mode-id")
- mounted.remove_presentation_root(
- root,
- restore_placeholder=!self.disposed,
- )
- }
- Headless => ()
- }
- }
- None =>
- model_data.dispose_code_or_headless(() => {
- self.did_detach_model_for_diagnostics.fire(())
- })
+ (Mounted(mounted), None) =>
+ mounted.container.remove_attribute("data-mode-id")
+ (Headless, _) => ()
}
self.model_slot.clear_current_after_dispose()
self.configuration.reset_model_line_counts()
@@ -722,7 +702,9 @@ fn Viewer::detach_model(self : Viewer) -> @model.TextModel? {
///|
/// Returns the current model installed on the viewer.
-pub fn Viewer::get_model(self : Viewer) -> @model.TextModel? {
+pub fn CodeEditorWidget::get_model(
+ self : CodeEditorWidget,
+) -> @model.TextModel? {
match self.model_slot.current {
Some(model_data) => Some(model_data.model)
None => None
@@ -732,13 +714,15 @@ pub fn Viewer::get_model(self : Viewer) -> @model.TextModel? {
///|
/// Whether a model is installed — `hasModel`, Monaco's type-guard companion to
/// `getModel`.
-fn Viewer::has_model(self : Viewer) -> Bool {
+fn CodeEditorWidget::has_model(self : CodeEditorWidget) -> Bool {
self.model_slot.current is Some(_)
}
///|
/// The attached bundle's view model — Monaco's `_getViewModel()`.
-fn Viewer::get_view_model(self : Viewer) -> @view_model.ViewModel? {
+fn CodeEditorWidget::get_view_model(
+ self : CodeEditorWidget,
+) -> @view_model.ViewModel? {
match self.model_slot.current {
Some(model_data) => Some(model_data.view_model)
None => None
@@ -748,7 +732,7 @@ fn Viewer::get_view_model(self : Viewer) -> @view_model.ViewModel? {
///|
/// The attached bundle's view — `None` with no model, and `None` headless
/// (the `hasRealView = false` analog).
-fn Viewer::current_code_view(self : Viewer) -> @view.View? {
+fn CodeEditorWidget::current_code_view(self : CodeEditorWidget) -> @view.View? {
match self.current_code_browser_data() {
Some(browser) => Some(browser.view)
None => None
@@ -758,32 +742,38 @@ fn Viewer::current_code_view(self : Viewer) -> @view.View? {
///|
/// The current options snapshot — the read side of `update_options` (Monaco's
/// `getOptions`/`getRawOptions`, collapsed onto the one options struct).
-pub fn Viewer::get_options(self : Viewer) -> ViewerOptions {
+pub fn CodeEditorWidget::get_options(
+ self : CodeEditorWidget,
+) -> CodeEditorOptions {
self.configuration.snapshot().options
}
///|
/// This editor's unique id, Monaco's `getId()`: the editor type plus the
/// process-wide instance counter.
-pub fn Viewer::get_id(self : Viewer) -> String {
+pub fn CodeEditorWidget::get_id(self : CodeEditorWidget) -> String {
"\{self.get_editor_type()}:\{self.editor_id.to_string()}"
}
///|
/// The editor's own root element — `getDomNode` (`cew:1489-1494`): the
/// view's node only while a model is attached with a real view.
-pub fn Viewer::get_dom_node(self : Viewer) -> @rdom.Element? {
+pub fn CodeEditorWidget::get_dom_node(
+ self : CodeEditorWidget,
+) -> @rdom.Element? {
self.active_presentation_root()
}
///|
/// The original host container element — `getContainerDomNode`. Public
-/// Viewers are created with `Viewer::create`, so the host is always present
-/// and remains queryable after Viewer disposal.
-pub fn Viewer::get_container_dom_node(self : Viewer) -> @rdom.Element {
+/// Viewers are created with `CodeEditorWidget::create`, so the host is always present
+/// and remains queryable after CodeEditorWidget disposal.
+pub fn CodeEditorWidget::get_container_dom_node(
+ self : CodeEditorWidget,
+) -> @rdom.Element {
match self.mount {
Mounted(mounted) => mounted.container
- Headless => abort("Viewer is not attached")
+ Headless => abort("CodeEditorWidget is not attached")
}
}
@@ -791,8 +781,8 @@ pub fn Viewer::get_container_dom_node(self : Viewer) -> @rdom.Element {
/// Focuses the editor's focusable surface — `focus()`. Monaco focuses the
/// hidden textarea / edit context; the viewer's focusable surface is the view
/// root itself.
-pub fn Viewer::focus(self : Viewer) -> Unit {
- match self.current_browser_presentation() {
+pub fn CodeEditorWidget::focus(self : CodeEditorWidget) -> Unit {
+ match self.current_code_browser_data() {
Some(presentation) => presentation.focus()
None => ()
}
@@ -800,7 +790,7 @@ pub fn Viewer::focus(self : Viewer) -> Unit {
///|
/// Whether the editor's text surface has DOM focus — `hasTextFocus()`.
-pub fn Viewer::has_text_focus(self : Viewer) -> Bool {
+pub fn CodeEditorWidget::has_text_focus(self : CodeEditorWidget) -> Bool {
match self.mount {
Headless => false
Mounted(mounted) => mounted.editor_has_focus
@@ -810,7 +800,9 @@ pub fn Viewer::has_text_focus(self : Viewer) -> Bool {
///|
/// Returns the current scroll state in host-restorable form — Monaco's
/// `saveViewState` (`cew:1038-1041`). No model → `None`.
-pub fn Viewer::save_view_state(self : Viewer) -> ViewerViewState? {
+pub fn CodeEditorWidget::save_view_state(
+ self : CodeEditorWidget,
+) -> CodeEditorViewState? {
guard self.active_scroll_position() is Some(position) else { return None }
Some({ scroll_top: position.scroll_top, scroll_left: position.scroll_left })
}
@@ -818,9 +810,9 @@ pub fn Viewer::save_view_state(self : Viewer) -> ViewerViewState? {
///|
/// Restores a previously captured scroll state — Monaco's `restoreViewState`
/// (`cew:1052`): a no-op without a model, and `None` state restores nothing.
-pub fn Viewer::restore_view_state(
- self : Viewer,
- state : ViewerViewState?,
+pub fn CodeEditorWidget::restore_view_state(
+ self : CodeEditorWidget,
+ state : CodeEditorViewState?,
) -> Unit {
match state {
Some(state) => {
@@ -840,14 +832,15 @@ pub fn Viewer::restore_view_state(
///|
/// Disposes viewer-owned external resources. Like Monaco's dispose, the model
/// detaches silently (no `onDidChangeModel`).
-pub fn Viewer::dispose(self : Viewer) -> Unit {
+pub fn CodeEditorWidget::dispose(self : CodeEditorWidget) -> Unit {
if self.disposed {
return
}
self.disposed = true
self.cancel_render_frame()
+ self.dispose_managed_overview_rulers()
// `CodeEditorWidget.dispose` clears widget registries before model teardown.
- // The contribution instances and Viewer-lifetime store remain live until
+ // The contribution instances and CodeEditorWidget-lifetime store remain live until
// after `onDidDispose` fires (they are owned by the source base store).
self.overlay_widgets.clear()
// The model detaches silently (no `onDidChangeModel`), but the owner-id
@@ -857,7 +850,7 @@ pub fn Viewer::dispose(self : Viewer) -> Unit {
detached.remove_all_decorations_with_owner_id(self.editor_id)
None => ()
}
- // Viewer-owned attach DOM is removed before the dispose event. The caller's
+ // CodeEditorWidget-owned attach DOM is removed before the dispose event. The caller's
// original host remains mounted and retained for `get_container_dom_node`.
match self.mount {
Headless => ()
@@ -887,6 +880,7 @@ pub fn Viewer::dispose(self : Viewer) -> Unit {
self.did_scroll.dispose()
self.did_change_view_zones.dispose()
self.did_change_hidden_areas.dispose()
+ self.did_render.dispose()
self.did_dispose.dispose()
self.did_mouse_down.dispose()
self.did_mouse_up.dispose()
@@ -909,7 +903,7 @@ pub fn Viewer::dispose(self : Viewer) -> Unit {
/// Re-measures the viewer box after a host-observed layout change —
/// `layout()`. The explicit-dimension form is not ported: the viewer always
/// measures its host element.
-pub fn Viewer::layout(self : Viewer) -> Unit {
+pub fn CodeEditorWidget::layout(self : CodeEditorWidget) -> Unit {
self.configuration.request_measure()
self.schedule_render()
}
@@ -918,14 +912,20 @@ pub fn Viewer::layout(self : Viewer) -> Unit {
/// Sets the vertical synthetic scroll position — `setScrollTop`
/// (`cew:1014-1017`, no model → no-op). Wheel and scrollbar input arrive
/// through the view's own listeners.
-pub fn Viewer::set_scroll_top(self : Viewer, new_scroll_top : Double) -> Unit {
+pub fn CodeEditorWidget::set_scroll_top(
+ self : CodeEditorWidget,
+ new_scroll_top : Double,
+) -> Unit {
self.set_active_scroll_position(scroll_top=new_scroll_top)
}
///|
/// Sets the horizontal synthetic scroll position — `setScrollLeft`
/// (`cew:1003-1006`, no model → no-op).
-pub fn Viewer::set_scroll_left(self : Viewer, new_scroll_left : Double) -> Unit {
+pub fn CodeEditorWidget::set_scroll_left(
+ self : CodeEditorWidget,
+ new_scroll_left : Double,
+) -> Unit {
self.set_active_scroll_position(scroll_left=new_scroll_left)
}
@@ -933,8 +933,8 @@ pub fn Viewer::set_scroll_left(self : Viewer, new_scroll_left : Double) -> Unit
/// Sets both synthetic scroll axes — `setScrollPosition` with Monaco's
/// `INewScrollPosition` semantics: an omitted axis keeps its position
/// (`cew:1025-1028`, no model → no-op).
-pub fn Viewer::set_scroll_position(
- self : Viewer,
+pub fn CodeEditorWidget::set_scroll_position(
+ self : CodeEditorWidget,
scroll_top? : Double,
scroll_left? : Double,
) -> Unit {
@@ -942,8 +942,8 @@ pub fn Viewer::set_scroll_position(
}
///|
-fn Viewer::apply_scroll_change(
- self : Viewer,
+fn CodeEditorWidget::apply_scroll_change(
+ self : CodeEditorWidget,
change : @view_layout.ScrollChange?,
) -> Unit {
match change {
diff --git a/editor/viewer/viewer_mount.mbt b/editor/internal/viewer/code_editor_widget/viewer_mount.mbt
similarity index 93%
rename from editor/viewer/viewer_mount.mbt
rename to editor/internal/viewer/code_editor_widget/viewer_mount.mbt
index 09b559d98..38d72d117 100644
--- a/editor/viewer/viewer_mount.mbt
+++ b/editor/internal/viewer/code_editor_widget/viewer_mount.mbt
@@ -1,5 +1,5 @@
///|
-/// The browser-attachment state of a Viewer. Construction starts headless and
+/// The browser-attachment state of a CodeEditorWidget. Construction starts headless and
/// `attach` performs the sole transition to `Mounted`; disposal deliberately
/// retains the mounted host so `get_container_dom_node` remains valid.
priv enum ViewerMount {
@@ -8,8 +8,8 @@ priv enum ViewerMount {
}
///|
-/// Viewer-lifetime state that exists only after a host has been attached.
-/// Per-model browser state remains owned by `BrowserPresentation`.
+/// CodeEditorWidget-lifetime state that exists only after a host has been attached.
+/// Per-model browser state remains owned by `CodeBrowserData`.
priv struct MountedViewer {
container : @rdom.Element
mut placeholder : PlaceholderDom?
@@ -91,7 +91,7 @@ fn MountedViewer::schedule_render(
self.render_frame = Some(
@base_browser.run_at_this_or_schedule_at_next_animation_frame(
() => {
- // The scheduled slot is idle before any Viewer callback runs.
+ // The scheduled slot is idle before any CodeEditorWidget callback runs.
self.render_frame = None
callback()
},
diff --git a/editor/viewer/viewer_options.mbt b/editor/internal/viewer/code_editor_widget/viewer_options.mbt
similarity index 83%
rename from editor/viewer/viewer_options.mbt
rename to editor/internal/viewer/code_editor_widget/viewer_options.mbt
index 2560a84b8..ac932e3cf 100644
--- a/editor/viewer/viewer_options.mbt
+++ b/editor/internal/viewer/code_editor_widget/viewer_options.mbt
@@ -1,6 +1,6 @@
///|
/// Runtime viewer options that affect view-model construction and layout.
-struct ViewerOptions {
+struct CodeEditorOptions {
soft_wrap : Bool
tab_size : Int
wrapping_indent : @editor_api.WrappingIndent
@@ -78,7 +78,7 @@ struct ViewerOptions {
/// `render_line_highlight` defaults to `Line` and
/// `render_line_highlight_only_when_focus` to `false`, Monaco's defaults for
/// the current-line highlight (`editorOptions.ts`).
-pub fn ViewerOptions::ViewerOptions(
+pub fn CodeEditorOptions::CodeEditorOptions(
soft_wrap? : Bool = false,
tab_size? : Int = 2,
wrapping_indent? : @editor_api.WrappingIndent = Same,
@@ -104,7 +104,7 @@ pub fn ViewerOptions::ViewerOptions(
font_size? : Double = @config.editor_font_defaults.font_size,
line_height? : Double = @config.editor_font_defaults.line_height,
letter_spacing? : Double = @config.editor_font_defaults.letter_spacing,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{
soft_wrap,
tab_size: tab_size.max(1),
@@ -138,9 +138,9 @@ pub fn ViewerOptions::ViewerOptions(
/// Whether the two option sets agree on every field that feeds view-model
/// construction or layout — `theme`, `placeholder`, and the root-class flags
/// (`show_unused`/`show_deprecated`) apply without a rebuild.
-fn ViewerOptions::same_layout_options(
- self : ViewerOptions,
- other : ViewerOptions,
+fn CodeEditorOptions::same_layout_options(
+ self : CodeEditorOptions,
+ other : CodeEditorOptions,
) -> Bool {
{
..self,
@@ -155,147 +155,147 @@ fn ViewerOptions::same_layout_options(
///|
/// Default readonly viewer options.
-pub fn ViewerOptions::default() -> ViewerOptions {
- ViewerOptions()
+pub fn CodeEditorOptions::default() -> CodeEditorOptions {
+ CodeEditorOptions()
}
///|
/// Returns a copy with Monaco's no-model placeholder changed.
-pub fn ViewerOptions::with_placeholder(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_placeholder(
+ self : CodeEditorOptions,
placeholder : String,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, placeholder, }
}
///|
/// Returns a copy with the standalone theme changed.
-pub fn ViewerOptions::with_theme(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_theme(
+ self : CodeEditorOptions,
theme : String,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, theme, }
}
///|
/// Returns a copy with the measured editor font family changed.
-pub fn ViewerOptions::with_font_family(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_font_family(
+ self : CodeEditorOptions,
font_family : String,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, font_family, }
}
///|
/// Returns a copy with the measured editor font size changed.
-pub fn ViewerOptions::with_font_size(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_font_size(
+ self : CodeEditorOptions,
font_size : Double,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, font_size, }
}
///|
/// Returns a copy with `renderWhitespace` changed.
-pub fn ViewerOptions::with_render_whitespace(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_render_whitespace(
+ self : CodeEditorOptions,
render_whitespace : @editor_api.RenderWhitespace,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, render_whitespace, }
}
///|
/// Returns a copy with `renderControlCharacters` changed.
-pub fn ViewerOptions::with_render_control_characters(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_render_control_characters(
+ self : CodeEditorOptions,
render_control_characters : Bool,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, render_control_characters, }
}
///|
/// Returns a copy with `renderLineHighlight` changed.
-pub fn ViewerOptions::with_render_line_highlight(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_render_line_highlight(
+ self : CodeEditorOptions,
render_line_highlight : @editor_api.RenderLineHighlight,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, render_line_highlight, }
}
///|
/// Returns a copy with the focused-only line-highlight gate changed.
-pub fn ViewerOptions::with_render_line_highlight_only_when_focus(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_render_line_highlight_only_when_focus(
+ self : CodeEditorOptions,
render_line_highlight_only_when_focus : Bool,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, render_line_highlight_only_when_focus, }
}
///|
/// Returns a copy with `renderValidationDecorations` changed.
-pub fn ViewerOptions::with_render_validation_decorations(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_render_validation_decorations(
+ self : CodeEditorOptions,
render_validation_decorations : @editor_api.RenderValidationDecorations,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, render_validation_decorations, }
}
///|
/// Returns a copy with the tab size changed, normalized to at least one.
-pub fn ViewerOptions::with_tab_size(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_tab_size(
+ self : CodeEditorOptions,
tab_size : Int,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, tab_size: tab_size.max(1) }
}
///|
/// Returns a copy with soft wrapping enabled or disabled.
-pub fn ViewerOptions::with_soft_wrap(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_soft_wrap(
+ self : CodeEditorOptions,
soft_wrap : Bool,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, soft_wrap, }
}
///|
/// Returns a copy with the minimum line-number digit count changed, normalized
/// to at least one.
-pub fn ViewerOptions::with_line_numbers_min_chars(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_line_numbers_min_chars(
+ self : CodeEditorOptions,
line_numbers_min_chars : Int,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, line_numbers_min_chars: line_numbers_min_chars.max(1) }
}
///|
/// Returns a copy with the line-decoration gutter width changed, normalized to
/// a non-negative value.
-pub fn ViewerOptions::with_line_decorations_width(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_line_decorations_width(
+ self : CodeEditorOptions,
line_decorations_width : Int,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, line_decorations_width: line_decorations_width.max(0) }
}
///|
/// Returns a copy with the requested line height changed.
-pub fn ViewerOptions::with_line_height(
- self : ViewerOptions,
+pub fn CodeEditorOptions::with_line_height(
+ self : CodeEditorOptions,
line_height : Double,
-) -> ViewerOptions {
+) -> CodeEditorOptions {
{ ..self, line_height, }
}
///|
/// Whether soft wrapping is enabled in this snapshot.
-pub fn ViewerOptions::soft_wrap(self : ViewerOptions) -> Bool {
+pub fn CodeEditorOptions::soft_wrap(self : CodeEditorOptions) -> Bool {
self.soft_wrap
}
///|
/// The requested line height in this snapshot.
-pub fn ViewerOptions::line_height(self : ViewerOptions) -> Double {
+pub fn CodeEditorOptions::line_height(self : CodeEditorOptions) -> Double {
self.line_height
}
@@ -304,8 +304,8 @@ pub fn ViewerOptions::line_height(self : ViewerOptions) -> Double {
/// `BareFontInfo.createFromValidatedSettings`. The viewer has no
/// `fontLigatures` option, so `font-feature-settings` is constantly
/// `EditorFontLigatures.OFF` (Monaco's default).
-fn ViewerOptions::bare_font_info(
- self : ViewerOptions,
+fn CodeEditorOptions::bare_font_info(
+ self : CodeEditorOptions,
pixel_ratio~ : Double,
) -> @config.BareFontInfo {
@config.BareFontInfo::create(
@@ -323,8 +323,8 @@ fn ViewerOptions::bare_font_info(
///|
/// Computes the soft-wrap column that fits the measured content width, or zero
/// when wrapping is disabled or measurement is not ready.
-fn ViewerOptions::soft_wrap_column_for_content_width(
- self : ViewerOptions,
+fn CodeEditorOptions::soft_wrap_column_for_content_width(
+ self : CodeEditorOptions,
content_width : Double,
char_width : Double,
) -> Int {
@@ -339,8 +339,8 @@ fn ViewerOptions::soft_wrap_column_for_content_width(
/// Converts browser-level options into backend-neutral view-model options.
/// `font_info` is the viewer's measured font (Monaco's `EditorOption.fontInfo`),
/// not an option field, so it rides in as an argument.
-fn ViewerOptions::view_model_options(
- self : ViewerOptions,
+fn CodeEditorOptions::view_model_options(
+ self : CodeEditorOptions,
soft_wrap_column : Int,
font_info~ : @config.FontInfo,
editor_id? : Int = 0,
@@ -365,4 +365,4 @@ fn ViewerOptions::view_model_options(
}
///|
-pub extend ViewerOptions with Eq::{equal, not_equal}
+pub extend CodeEditorOptions with Eq::{equal, not_equal}
diff --git a/editor/viewer/viewer_options_test.mbt b/editor/internal/viewer/code_editor_widget/viewer_options_test.mbt
similarity index 64%
rename from editor/viewer/viewer_options_test.mbt
rename to editor/internal/viewer/code_editor_widget/viewer_options_test.mbt
index 494894dc7..eac2c5fc9 100644
--- a/editor/viewer/viewer_options_test.mbt
+++ b/editor/internal/viewer/code_editor_widget/viewer_options_test.mbt
@@ -1,6 +1,6 @@
///|
-test "ViewerOptions builders preserve an immutable opaque snapshot" {
- let original = ViewerOptions::default()
+test "CodeEditorOptions builders preserve an immutable opaque snapshot" {
+ let original = CodeEditorOptions::default()
let updated = original
.with_placeholder("loading")
.with_theme("light")
@@ -16,7 +16,7 @@ test "ViewerOptions builders preserve an immutable opaque snapshot" {
.with_line_height(21.0)
assert_eq(
updated,
- ViewerOptions(
+ CodeEditorOptions(
placeholder="loading",
theme="light",
render_whitespace=All,
@@ -31,24 +31,24 @@ test "ViewerOptions builders preserve an immutable opaque snapshot" {
line_height=21.0,
),
)
- assert_eq(original, ViewerOptions::default())
+ assert_eq(original, CodeEditorOptions::default())
assert_false(original.soft_wrap())
assert_true(updated.soft_wrap())
assert_eq(updated.line_height(), 21.0)
}
///|
-test "ViewerOptions builders preserve constructor normalization" {
+test "CodeEditorOptions builders preserve constructor normalization" {
assert_eq(
- ViewerOptions::default().with_tab_size(0),
- ViewerOptions(tab_size=1),
+ CodeEditorOptions::default().with_tab_size(0),
+ CodeEditorOptions(tab_size=1),
)
assert_eq(
- ViewerOptions::default().with_line_numbers_min_chars(0),
- ViewerOptions(line_numbers_min_chars=1),
+ CodeEditorOptions::default().with_line_numbers_min_chars(0),
+ CodeEditorOptions(line_numbers_min_chars=1),
)
assert_eq(
- ViewerOptions::default().with_line_decorations_width(-1),
- ViewerOptions(line_decorations_width=0),
+ CodeEditorOptions::default().with_line_decorations_width(-1),
+ CodeEditorOptions(line_decorations_width=0),
)
}
diff --git a/editor/viewer/viewer_options_wbtest.mbt b/editor/internal/viewer/code_editor_widget/viewer_options_wbtest.mbt
similarity index 80%
rename from editor/viewer/viewer_options_wbtest.mbt
rename to editor/internal/viewer/code_editor_widget/viewer_options_wbtest.mbt
index 7317af533..4fea0f24c 100644
--- a/editor/viewer/viewer_options_wbtest.mbt
+++ b/editor/internal/viewer/code_editor_widget/viewer_options_wbtest.mbt
@@ -1,6 +1,6 @@
///|
test "viewer options default to no soft wrap view model options" {
- let options = ViewerOptions(tab_size=7)
+ let options = CodeEditorOptions(tab_size=7)
assert_false(options.smooth_scrolling)
assert_eq(options.soft_wrap_column_for_content_width(720.0, 7.2), 0)
let view_model_options = options.view_model_options(
@@ -13,7 +13,7 @@ test "viewer options default to no soft wrap view model options" {
///|
test "viewer options derive soft wrap columns from measured glyphs" {
- let options = ViewerOptions(soft_wrap=true, tab_size=4)
+ let options = CodeEditorOptions(soft_wrap=true, tab_size=4)
assert_eq(options.soft_wrap_column_for_content_width(72.0, 7.2), 10)
let view_model_options = options.view_model_options(
10,
@@ -26,7 +26,7 @@ test "viewer options derive soft wrap columns from measured glyphs" {
///|
test "viewer font-family updates preserve the remaining options" {
- let updated = ViewerOptions(placeholder="Choose a file").with_font_family(
+ let updated = CodeEditorOptions(placeholder="Choose a file").with_font_family(
"Example Mono, monospace",
)
assert_eq(updated.font_family, "Example Mono, monospace")
@@ -35,7 +35,9 @@ test "viewer font-family updates preserve the remaining options" {
///|
test "viewer font-size updates preserve the remaining options" {
- let updated = ViewerOptions(placeholder="Choose a file").with_font_size(14.0)
+ let updated = CodeEditorOptions(placeholder="Choose a file").with_font_size(
+ 14.0,
+ )
assert_eq(updated.font_size, 14.0)
assert_eq(updated.placeholder, "Choose a file")
}
diff --git a/editor/internal/viewer/code_editor_widget/virtualized_render_lines.mbt b/editor/internal/viewer/code_editor_widget/virtualized_render_lines.mbt
new file mode 100644
index 000000000..09628f97a
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/virtualized_render_lines.mbt
@@ -0,0 +1,558 @@
+// Viewport-driven detached-line rendering for tall editor-owned ViewZones.
+// The retained handle owns its source projection and reacts synchronously to
+// ViewZones' explicit visibility/top geometry callback. Higher layers never observe a
+// browser View, configuration object, or scroll scheduler.
+
+///|
+priv struct VirtualizedRenderLinesWindow {
+ start_offset : Int
+ end_offset_exclusive : Int
+ visible : Bool
+}
+
+///|
+fn virtualized_render_lines_window(
+ total_view_lines : Int,
+ line_height : Double,
+ viewport_height : Double,
+ zone_viewport_top : Double,
+ overscan_lines : Int,
+ zone_visible? : Bool = true,
+) -> VirtualizedRenderLinesWindow {
+ if !zone_visible ||
+ total_view_lines <= 0 ||
+ line_height <= 0.0 ||
+ viewport_height <= 0.0 {
+ return { start_offset: 0, end_offset_exclusive: 0, visible: false }
+ }
+ let visible_start = ((-zone_viewport_top).max(0.0) / line_height)
+ .floor()
+ .to_int()
+ let visible_end = ((viewport_height - zone_viewport_top).max(0.0) /
+ line_height)
+ .ceil()
+ .to_int()
+ let start_offset = (visible_start - overscan_lines).clamp(
+ min=0,
+ max=total_view_lines,
+ )
+ let end_offset_exclusive = (visible_end + overscan_lines).clamp(
+ min=start_offset,
+ max=total_view_lines,
+ )
+ {
+ start_offset,
+ end_offset_exclusive,
+ visible: end_offset_exclusive > start_offset,
+ }
+}
+
+///|
+pub struct VirtualizedCodeEditorRenderLinesContext {
+ editor : CodeEditorWidget
+ source_model : @model.TextModel
+ source_version : Int
+ detached_view_model : @view_model.ViewModel
+ configuration_options : CodeEditorOptions
+ configuration_font_info : @config.FontInfo
+ configuration_width : Double
+ configuration_content_width : Double
+ configuration_height : Double
+ configuration_soft_wrap_column : Int
+ owner_generation : Int
+ handles : Array[VirtualizedCodeEditorRenderedLines]
+ mut created_handle_count : Int
+ mut live_handle_count : Int
+ mut registered_zone_count : Int
+ mut disposed : Bool
+}
+
+///|
+pub struct VirtualizedCodeEditorRenderedLines {
+ context : VirtualizedCodeEditorRenderLinesContext
+ decorations : Array[CodeEditorRenderLinesDecoration]
+ line_class_name : String
+ margin_line_class_name : String
+ dom_node : @rdom.Element
+ margin_dom_node : @rdom.Element
+ range_view_start : Int
+ range_view_end_exclusive : Int
+ line_height : Double
+ height_in_px : Double
+ mut discovered_min_width_in_px : Double
+ mut zone_id : String?
+ mut zone_delegate : @editor_browser.ViewZone?
+ mut rendered_start_offset : Int
+ mut rendered_end_offset_exclusive : Int
+ mut rendered_visible : Bool
+ mut disposed : Bool
+}
+
+///|
+fn VirtualizedCodeEditorRenderedLines::configuration_snapshot(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> EditorConfigurationSnapshot {
+ {
+ options: self.context.configuration_options,
+ font_info: self.context.configuration_font_info,
+ width: self.context.configuration_width,
+ content_width: self.context.configuration_content_width,
+ height: self.context.configuration_height,
+ soft_wrap_column: self.context.configuration_soft_wrap_column,
+ }
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderLinesContext::owner_generation(
+ self : VirtualizedCodeEditorRenderLinesContext,
+) -> Int {
+ self.owner_generation
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderLinesContext::created_handle_count(
+ self : VirtualizedCodeEditorRenderLinesContext,
+) -> Int {
+ self.created_handle_count
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderLinesContext::live_handle_count(
+ self : VirtualizedCodeEditorRenderLinesContext,
+) -> Int {
+ self.live_handle_count
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderLinesContext::registered_zone_count(
+ self : VirtualizedCodeEditorRenderLinesContext,
+) -> Int {
+ self.registered_zone_count
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderLinesContext::is_disposed(
+ self : VirtualizedCodeEditorRenderLinesContext,
+) -> Bool {
+ self.disposed
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::dom_node(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> @rdom.Element {
+ self.dom_node
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::margin_dom_node(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> @rdom.Element {
+ self.margin_dom_node
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::height_in_px(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> Double {
+ self.height_in_px
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::min_width_in_px(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> Double {
+ self.discovered_min_width_in_px
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::view_line_count(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> Int {
+ self.range_view_end_exclusive - self.range_view_start
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::is_disposed(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> Bool {
+ self.disposed
+}
+
+///|
+fn VirtualizedCodeEditorRenderedLines::clear_window(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> Unit {
+ if self.rendered_visible || self.rendered_end_offset_exclusive > 0 {
+ self.dom_node.set_inner_html("")
+ self.margin_dom_node.set_inner_html("")
+ }
+ self.rendered_start_offset = 0
+ self.rendered_end_offset_exclusive = 0
+ self.rendered_visible = false
+ self.dom_node.set_attribute("data-rendered-view-line-count", "0")
+}
+
+///|
+fn VirtualizedCodeEditorRenderedLines::update_zone_min_width(
+ self : VirtualizedCodeEditorRenderedLines,
+ min_width_in_px : Double,
+) -> Unit {
+ let next_width = min_width_in_px.max(0.0).min(1000000.0)
+ if (next_width - self.discovered_min_width_in_px).abs() <= 0.5 {
+ return
+ }
+ self.discovered_min_width_in_px = next_width
+ self.dom_node.set_attribute(
+ "data-discovered-min-width",
+ next_width.to_string(),
+ )
+ guard !self.context.editor.disposed &&
+ self.zone_id is Some(zone_id) &&
+ self.zone_delegate is Some(zone_delegate) else {
+ return
+ }
+ zone_delegate.min_width_in_px = Some(next_width)
+ self.context.editor.change_view_zones(accessor => {
+ accessor.layout_zone(zone_id)
+ })
+}
+
+///|
+fn VirtualizedCodeEditorRenderedLines::bind_zone(
+ self : VirtualizedCodeEditorRenderedLines,
+ zone_id : String,
+ zone_delegate : @editor_browser.ViewZone,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ if self.zone_id is None {
+ self.context.registered_zone_count += 1
+ }
+ self.zone_id = Some(zone_id)
+ self.zone_delegate = Some(zone_delegate)
+ if self.discovered_min_width_in_px > 0.0 {
+ zone_delegate.min_width_in_px = Some(self.discovered_min_width_in_px)
+ self.context.editor.change_view_zones(accessor => {
+ accessor.layout_zone(zone_id)
+ })
+ }
+}
+
+///|
+fn VirtualizedCodeEditorRenderedLines::render_for_zone_layout(
+ self : VirtualizedCodeEditorRenderedLines,
+ visible : Bool,
+ zone_viewport_top : Double,
+) -> Unit {
+ if self.disposed ||
+ self.context.disposed ||
+ self.context.editor.disposed ||
+ self.context.source_model.is_disposed() ||
+ self.context.source_model.get_version_id() != self.context.source_version {
+ self.clear_window()
+ return
+ }
+ let viewport_height = self.context.editor.configuration.snapshot().height.max(
+ self.line_height * 20.0,
+ )
+ let window = virtualized_render_lines_window(
+ self.view_line_count(),
+ self.line_height,
+ viewport_height,
+ zone_viewport_top,
+ 8,
+ zone_visible=visible,
+ )
+ if window.start_offset == self.rendered_start_offset &&
+ window.end_offset_exclusive == self.rendered_end_offset_exclusive &&
+ window.visible == self.rendered_visible {
+ return
+ }
+ if !window.visible {
+ self.clear_window()
+ return
+ }
+ let window_view_start = self.range_view_start + window.start_offset
+ let window_view_end_exclusive = self.range_view_start +
+ window.end_offset_exclusive
+ let converter = self.context.detached_view_model.coordinates_converter()
+ let first_data = self.context.detached_view_model
+ .lines()
+ .view_line_rendering_data(window_view_start)
+ let final_view_line = window_view_end_exclusive - 1
+ let final_data = self.context.detached_view_model
+ .lines()
+ .view_line_rendering_data(final_view_line)
+ let first_model_line = converter.view_position_to_model_position(
+ Position(window_view_start, first_data.min_column),
+ ).line_number
+ let final_model_line = converter.view_position_to_model_position(
+ Position(final_view_line, final_data.max_column),
+ ).line_number
+ stabilize_render_lines_tokens(
+ self.context.source_model,
+ first_model_line,
+ final_model_line + 1,
+ )
+ let rendered = render_detached_view_line_window(
+ self.context.detached_view_model,
+ self.configuration_snapshot(),
+ self.range_view_start,
+ window_view_start,
+ window_view_end_exclusive,
+ self.decorations,
+ self.line_class_name,
+ self.margin_line_class_name,
+ )
+ self.dom_node.set_inner_html(rendered.content_html)
+ self.margin_dom_node.set_inner_html(rendered.margin_html)
+ self.dom_node.set_attribute(
+ "data-render-window-start-view-line",
+ window_view_start.to_string(),
+ )
+ self.dom_node.set_attribute(
+ "data-render-window-end-exclusive-view-line",
+ window_view_end_exclusive.to_string(),
+ )
+ self.dom_node.set_attribute(
+ "data-rendered-view-line-count",
+ (window_view_end_exclusive - window_view_start).to_string(),
+ )
+ self.dom_node.set_attribute(
+ "data-render-window-start-model-line",
+ first_model_line.to_string(),
+ )
+ self.dom_node.set_attribute(
+ "data-render-window-end-model-line",
+ final_model_line.to_string(),
+ )
+ self.rendered_start_offset = window.start_offset
+ self.rendered_end_offset_exclusive = window.end_offset_exclusive
+ self.rendered_visible = window.visible
+ self.update_zone_min_width(
+ self.discovered_min_width_in_px.max(rendered.max_content_width + 16.0),
+ )
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::managed_view_zone_spec(
+ self : VirtualizedCodeEditorRenderedLines,
+ after_line_number : Int,
+ ordinal? : Int,
+ suppress_mouse_down? : Bool = false,
+) -> ManagedCodeEditorViewZoneSpec {
+ ManagedCodeEditorViewZoneSpec(
+ after_line_number,
+ self.height_in_px,
+ self.dom_node,
+ margin_dom_node=self.margin_dom_node,
+ min_width_in_px=0.0,
+ ordinal?,
+ suppress_mouse_down~,
+ on_dom_node_layout=(visible, top) => {
+ self.render_for_zone_layout(visible, top)
+ },
+ on_zone_registered=(zone_id, zone_delegate) => {
+ self.bind_zone(zone_id, zone_delegate)
+ },
+ )
+}
+
+///|
+/// Reveals this handle's installed ViewZone at its exact content-space top.
+/// Returns false before registration, after disposal, or after its editor/model
+/// lifetime ended.
+pub fn VirtualizedCodeEditorRenderedLines::reveal_zone_top(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> Bool {
+ if self.disposed || self.context.disposed {
+ return false
+ }
+ guard self.zone_id is Some(zone_id) else { return false }
+ self.context.editor.reveal_managed_view_zone_top(zone_id)
+}
+
+///|
+fn VirtualizedCodeEditorRenderedLines::dispose_handle(
+ self : VirtualizedCodeEditorRenderedLines,
+ update_zone_min_width? : Bool = true,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ if update_zone_min_width {
+ self.update_zone_min_width(0.0)
+ }
+ self.disposed = true
+ self.clear_window()
+ if self.zone_id is Some(_) {
+ self.context.registered_zone_count = (self.context.registered_zone_count - 1).max(
+ 0,
+ )
+ }
+ self.zone_id = None
+ self.zone_delegate = None
+ self.context.live_handle_count = (self.context.live_handle_count - 1).max(0)
+ self.dom_node.set_attribute("data-virtualized-render-lines-disposed", "true")
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderedLines::dispose(
+ self : VirtualizedCodeEditorRenderedLines,
+) -> Unit {
+ self.dispose_handle()
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderLinesContext::dispose(
+ self : VirtualizedCodeEditorRenderLinesContext,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ // Mark the context first so an owning convenience handle cannot recurse
+ // through `dispose` while the shared handle set is being drained.
+ self.disposed = true
+ for handle in self.handles {
+ // The owning DiffEditor removes every old zone in one managed-zone
+ // transaction. Per-handle width relayout here would turn a fragmented
+ // generation teardown into O(hunks) layout transactions.
+ handle.dispose_handle(update_zone_min_width=false)
+ }
+ self.handles.clear()
+ self.detached_view_model.dispose()
+ self.registered_zone_count = 0
+}
+
+///|
+let virtualized_render_lines_context_view_model_creations : Ref[Int] = Ref(0)
+
+///|
+/// Creates one detached rendering context for a complete diff generation.
+/// Every deleted-zone handle for that generation must come from this context,
+/// so fragmented diffs retain one ViewModel/listener graph rather than one per
+/// hunk.
+pub fn CodeEditorWidget::create_render_lines_context(
+ self : CodeEditorWidget,
+ source_model : @model.TextModel,
+ owner_generation? : Int = 0,
+) -> VirtualizedCodeEditorRenderLinesContext? {
+ if self.disposed || source_model.is_disposed() {
+ return None
+ }
+ let configuration = self.configuration.snapshot()
+ let detached_view_model = @view_model.ViewModel::with_options(
+ source_model,
+ configuration.view_model_options(0),
+ )
+ virtualized_render_lines_context_view_model_creations.val += 1
+ Some({
+ editor: self,
+ source_model,
+ source_version: source_model.get_version_id(),
+ detached_view_model,
+ configuration_options: configuration.options,
+ configuration_font_info: configuration.font_info,
+ configuration_width: configuration.width,
+ configuration_content_width: configuration.content_width,
+ configuration_height: configuration.height,
+ configuration_soft_wrap_column: configuration.soft_wrap_column,
+ owner_generation,
+ handles: [],
+ created_handle_count: 0,
+ live_handle_count: 0,
+ registered_zone_count: 0,
+ disposed: false,
+ })
+}
+
+///|
+fn VirtualizedCodeEditorRenderLinesContext::render_lines_impl(
+ self : VirtualizedCodeEditorRenderLinesContext,
+ line_range : @base_common.LineRange,
+ decorations : Array[CodeEditorRenderLinesDecoration],
+ line_class_name : String,
+ margin_line_class_name : String,
+) -> VirtualizedCodeEditorRenderedLines? {
+ if self.disposed ||
+ self.editor.disposed ||
+ self.source_model.is_disposed() ||
+ self.source_model.get_version_id() != self.source_version {
+ return None
+ }
+ let start_line_number = line_range.start_line_number.max(1)
+ let end_line_number_exclusive = line_range.end_line_number_exclusive.min(
+ self.source_model.get_line_count() + 1,
+ )
+ if start_line_number >= end_line_number_exclusive {
+ return None
+ }
+ let converter = self.detached_view_model.coordinates_converter()
+ let range_view_start = converter.model_position_to_view_position(
+ Position(start_line_number, 1),
+ ).line_number
+ let final_model_line = end_line_number_exclusive - 1
+ let range_view_end_exclusive = converter.model_position_to_view_position(
+ Position(
+ final_model_line,
+ self.source_model.get_line_max_column(final_model_line),
+ ),
+ ).line_number +
+ 1
+ let line_height = self.configuration_font_info.line_height.to_double()
+ let view_line_count = range_view_end_exclusive - range_view_start
+ let dom_node = @rdom.document().create_element("div")
+ dom_node.set_attribute("class", "view-lines")
+ dom_node.set_attribute("data-virtualized-render-lines", "true")
+ dom_node.set_attribute(
+ "data-render-lines-generation",
+ self.owner_generation.to_string(),
+ )
+ let margin_dom_node = @rdom.document().create_element("div")
+ margin_dom_node.set_attribute("data-virtualized-render-lines", "true")
+ margin_dom_node.set_attribute(
+ "data-render-lines-generation",
+ self.owner_generation.to_string(),
+ )
+ let bare_font_info = self.configuration_font_info.bare()
+ @browser_config.apply_font_info(dom_node, bare_font_info)
+ @browser_config.apply_font_info(margin_dom_node, bare_font_info)
+ let handle : VirtualizedCodeEditorRenderedLines = {
+ context: self,
+ decorations,
+ line_class_name,
+ margin_line_class_name,
+ dom_node,
+ margin_dom_node,
+ range_view_start,
+ range_view_end_exclusive,
+ line_height,
+ height_in_px: view_line_count.to_double() * line_height,
+ discovered_min_width_in_px: 0.0,
+ zone_id: None,
+ zone_delegate: None,
+ rendered_start_offset: 0,
+ rendered_end_offset_exclusive: 0,
+ rendered_visible: false,
+ disposed: false,
+ }
+ self.handles.push(handle)
+ self.created_handle_count += 1
+ self.live_handle_count += 1
+ Some(handle)
+}
+
+///|
+pub fn VirtualizedCodeEditorRenderLinesContext::render_lines(
+ self : VirtualizedCodeEditorRenderLinesContext,
+ line_range : @base_common.LineRange,
+ decorations? : Array[CodeEditorRenderLinesDecoration] = [],
+ line_class_name? : String = "",
+ margin_line_class_name? : String = "",
+) -> VirtualizedCodeEditorRenderedLines? {
+ self.render_lines_impl(
+ line_range, decorations, line_class_name, margin_line_class_name,
+ )
+}
diff --git a/editor/internal/viewer/code_editor_widget/virtualized_render_lines_wbtest.mbt b/editor/internal/viewer/code_editor_widget/virtualized_render_lines_wbtest.mbt
new file mode 100644
index 000000000..3c01fcc25
--- /dev/null
+++ b/editor/internal/viewer/code_editor_widget/virtualized_render_lines_wbtest.mbt
@@ -0,0 +1,179 @@
+///|
+extern "js" fn virtualized_render_lines_test_count(
+ element : @rdom.Element,
+) -> Int = "(element) => (element.innerHTML.match(/data-rendered-view-line=/g) || []).length"
+
+///|
+extern "js" fn virtualized_render_lines_test_html(
+ element : @rdom.Element,
+) -> String = "(element) => element.innerHTML"
+
+///|
+test "virtualized render window clamps top middle end and offscreen" {
+ let top = virtualized_render_lines_window(2600, 18.0, 900.0, 0.0, 8)
+ assert_eq(top.start_offset, 0)
+ assert_eq(top.end_offset_exclusive, 58)
+ assert_true(top.visible)
+ let middle = virtualized_render_lines_window(2600, 18.0, 900.0, -23400.0, 8)
+ assert_eq(middle.start_offset, 1292)
+ assert_eq(middle.end_offset_exclusive, 1358)
+ assert_true(middle.visible)
+ let tail = virtualized_render_lines_window(2600, 18.0, 900.0, -45900.0, 8)
+ assert_eq(tail.start_offset, 2542)
+ assert_eq(tail.end_offset_exclusive, 2600)
+ assert_true(tail.visible)
+ let million_pixel_visible = virtualized_render_lines_window(
+ 100000, 18.0, 900.0, -1000000.0, 8,
+ )
+ assert_true(million_pixel_visible.visible)
+ assert_true(million_pixel_visible.start_offset > 55000)
+ let offscreen = virtualized_render_lines_window(
+ 2600,
+ 18.0,
+ 900.0,
+ 0.0,
+ 8,
+ zone_visible=false,
+ )
+ assert_eq(offscreen.start_offset, 0)
+ assert_eq(offscreen.end_offset_exclusive, 0)
+ assert_false(offscreen.visible)
+}
+
+///|
+test "virtualized render-lines handle keeps a 2600-line zone below 200 DOM rows" {
+ with_native_render_lines_test_dom(() => {
+ let content = StringBuilder()
+ for index in 0..<2600 {
+ let wide_suffix = if index == 1300 {
+ " // " + "x".repeat(400)
+ } else {
+ ""
+ }
+ content.write_string("let original_\{index} = \{index}\{wide_suffix}\n")
+ }
+ let source = test_model(content.to_string())
+ defer source.dispose()
+ let options = CodeEditorOptions(font_size=12, line_height=18, tab_size=2)
+ with_test_viewer(
+ "host",
+ viewer => {
+ viewer.test_set_viewport(width=800.0, height=900.0)
+ let context = viewer
+ .create_render_lines_context(source, owner_generation=7)
+ .unwrap()
+ let rendered = context
+ .render_lines(
+ LineRange(1, 2601),
+ line_class_name="diff-editor-inline-deleted-line",
+ margin_line_class_name="diff-editor-inline-deleted-line-number",
+ )
+ .unwrap()
+ assert_eq(rendered.view_line_count(), 2600)
+ assert_eq(rendered.height_in_px(), 46800.0)
+ assert_eq(
+ rendered.dom_node().get_attribute("data-render-lines-generation"),
+ Some("7"),
+ )
+ assert_eq(rendered.min_width_in_px(), 0.0)
+ assert_eq(virtualized_render_lines_test_count(rendered.dom_node()), 0)
+ let spec = rendered.managed_view_zone_spec(0)
+ let render_window = spec.on_dom_node_layout.unwrap()
+ render_window(true, 0.0)
+ assert_true(
+ virtualized_render_lines_test_count(rendered.dom_node()) < 200,
+ )
+ assert_true(
+ virtualized_render_lines_test_html(rendered.dom_node()).contains(
+ "original_0",
+ ),
+ )
+ let top_min_width = rendered.min_width_in_px()
+ assert_true(top_min_width > 0.0)
+
+ render_window(true, -23400.0)
+ let middle_html = virtualized_render_lines_test_html(
+ rendered.dom_node(),
+ )
+ assert_true(middle_html.contains("original_1300"))
+ assert_true(
+ virtualized_render_lines_test_count(rendered.dom_node()) < 200,
+ )
+ assert_true(
+ virtualized_render_lines_test_html(rendered.margin_dom_node()).contains(
+ ">1301",
+ ),
+ )
+ let discovered_wide_window = rendered.min_width_in_px()
+ assert_true(discovered_wide_window > top_min_width)
+
+ render_window(true, -45900.0)
+ let tail_html = virtualized_render_lines_test_html(rendered.dom_node())
+ assert_true(tail_html.contains("original_2599"))
+ assert_true(
+ virtualized_render_lines_test_count(rendered.dom_node()) < 200,
+ )
+ assert_true(
+ virtualized_render_lines_test_html(rendered.margin_dom_node()).contains(
+ ">2600",
+ ),
+ )
+
+ render_window(false, -1000000.0)
+ assert_eq(virtualized_render_lines_test_count(rendered.dom_node()), 0)
+ assert_eq(rendered.min_width_in_px(), discovered_wide_window)
+ rendered.dispose()
+ assert_true(rendered.is_disposed())
+ context.dispose()
+ assert_true(context.is_disposed())
+ },
+ options~,
+ )
+ })
+}
+
+///|
+test "one generation shares one detached ViewModel across 1000 small zones" {
+ with_native_render_lines_test_dom(() => {
+ let content = StringBuilder()
+ for index in 0..<1000 {
+ content.write_string("let fragmented_\{index} = \{index}\n")
+ }
+ let source = test_model(content.to_string())
+ defer source.dispose()
+ let before = virtualized_render_lines_context_view_model_creations.val
+ with_test_viewer("host", viewer => {
+ let context = viewer
+ .create_render_lines_context(source, owner_generation=91)
+ .unwrap()
+ let handles : Array[VirtualizedCodeEditorRenderedLines] = []
+ for index in 0..<1000 {
+ let handle = context
+ .render_lines(LineRange(index + 1, index + 2))
+ .unwrap()
+ handle.bind_zone(
+ "fragmented-zone-\{index}",
+ view_zone(index, handle.dom_node()),
+ )
+ handles.push(handle)
+ }
+ assert_eq(
+ virtualized_render_lines_context_view_model_creations.val - before,
+ 1,
+ )
+ assert_eq(context.owner_generation(), 91)
+ assert_eq(context.created_handle_count(), 1000)
+ assert_eq(context.live_handle_count(), 1000)
+ assert_eq(context.registered_zone_count(), 1000)
+ assert_eq(virtualized_render_lines_test_count(handles[0].dom_node()), 0)
+ assert_eq(virtualized_render_lines_test_count(handles[999].dom_node()), 0)
+
+ context.dispose()
+ assert_true(context.is_disposed())
+ assert_eq(context.live_handle_count(), 0)
+ assert_eq(context.registered_zone_count(), 0)
+ assert_true(handles[0].is_disposed())
+ assert_true(handles[999].is_disposed())
+ })
+ })
+}
diff --git a/editor/internal/viewer/contrib/goto_symbol/moon.pkg b/editor/internal/viewer/contrib/goto_symbol/moon.pkg
new file mode 100644
index 000000000..6778fd199
--- /dev/null
+++ b/editor/internal/viewer/contrib/goto_symbol/moon.pkg
@@ -0,0 +1,15 @@
+import {
+ "moonbitlang/editor/base/common" @base_common,
+ "moonbitlang/editor/language",
+ "moonbitlang/editor/internal/viewer/contrib/references",
+ "moonbitlang/editor/viewer/common/languages",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
+}
+
+import {
+ "moonbitlang/async",
+ "moonbitlang/editor/platform/log",
+} for "wbtest"
+
+supported_targets = "js+native"
diff --git a/editor/internal/viewer/contrib/goto_symbol/pkg.generated.mbti b/editor/internal/viewer/contrib/goto_symbol/pkg.generated.mbti
new file mode 100644
index 000000000..a990b2030
--- /dev/null
+++ b/editor/internal/viewer/contrib/goto_symbol/pkg.generated.mbti
@@ -0,0 +1,94 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/editor/internal/viewer/contrib/goto_symbol"
+
+import {
+ "moonbitlang/core/debug",
+ "moonbitlang/editor/base/common",
+ "moonbitlang/editor/internal/viewer/contrib/references",
+ "moonbitlang/editor/language",
+ "moonbitlang/editor/viewer/common/languages",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
+}
+
+// Values
+pub fn symbol_navigation_context_commands(@languages.LanguageHandle, @model.TextModel, Bool) -> Array[SymbolNavigationCommand]
+
+// Errors
+
+// Types and methods
+pub struct DefinitionAction {
+ // private fields
+}
+pub fn DefinitionAction::DefinitionAction(SymbolNavigationAction) -> Self
+pub fn DefinitionAction::begin(Self, SymbolNavigationAnchor) -> Int?
+pub async fn DefinitionAction::run(Self, SymbolNavigationAnchor, SymbolNavigationActionConfig) -> Unit noraise
+pub async fn DefinitionAction::run_reserved(Self, SymbolNavigationAnchor, SymbolNavigationActionConfig, Int) -> Unit noraise
+
+pub struct DefinitionLinkResolver {
+ // private fields
+}
+pub fn DefinitionLinkResolver::DefinitionLinkResolver(@languages.LanguageHandle, is_anchor_current~ : (SymbolNavigationAnchor) -> Bool, async (Array[async () -> Unit]) -> Unit) -> Self
+pub fn DefinitionLinkResolver::begin(Self, SymbolNavigationAnchor) -> Int?
+pub fn DefinitionLinkResolver::cancel(Self) -> Unit
+pub fn DefinitionLinkResolver::dispose(Self) -> Unit
+pub async fn DefinitionLinkResolver::resolve_reserved(Self, SymbolNavigationAnchor, Int) -> Array[@language.Location] noraise
+
+pub struct ReferencesAction {
+ // private fields
+}
+pub fn ReferencesAction::ReferencesAction(SymbolNavigationAction) -> Self
+pub fn ReferencesAction::begin(Self, SymbolNavigationAnchor) -> Int?
+pub async fn ReferencesAction::run(Self, SymbolNavigationAnchor, SymbolNavigationActionConfig) -> Unit noraise
+pub async fn ReferencesAction::run_reserved(Self, SymbolNavigationAnchor, SymbolNavigationActionConfig, Int) -> Unit noraise
+
+pub struct SymbolNavigationAction {
+ // private fields
+}
+pub fn SymbolNavigationAction::SymbolNavigationAction(@languages.LanguageHandle, SymbolNavigationActionHost, async (Array[async () -> Unit]) -> Unit) -> Self
+pub fn SymbolNavigationAction::cancel(Self) -> Unit
+pub fn SymbolNavigationAction::dispose(Self) -> Unit
+
+pub(all) struct SymbolNavigationActionConfig {
+ open_to_side : Bool
+ open_in_peek : Bool
+ mute_message : Bool
+}
+pub fn SymbolNavigationActionConfig::goto() -> Self
+pub fn SymbolNavigationActionConfig::link() -> Self
+pub fn SymbolNavigationActionConfig::peek() -> Self
+
+pub(all) struct SymbolNavigationActionHost {
+ prepare_navigation : () -> Unit
+ is_anchor_current : (SymbolNavigationAnchor) -> Bool
+ open_location : async (SymbolNavigationAnchor, @language.Location, @navigation_api.OpenLocationMode, String) -> Bool noraise
+ open_in_peek : (SymbolNavigationAnchor, @references.ReferencesPeekMode, Array[@language.Location]) -> Bool
+ show_message : (SymbolNavigationAnchor, String) -> Unit
+}
+
+pub struct SymbolNavigationAnchor {
+ // private fields
+}
+pub fn SymbolNavigationAnchor::from_model(@model.TextModel, @common.Position, @common.Range, Int) -> Self
+pub fn SymbolNavigationAnchor::model(Self) -> @model.TextModel
+pub fn SymbolNavigationAnchor::model_is_current(Self) -> Bool
+pub fn SymbolNavigationAnchor::position(Self) -> @common.Position
+pub fn SymbolNavigationAnchor::range(Self) -> @common.Range
+pub fn SymbolNavigationAnchor::same_snapshot(Self, Self) -> Bool
+pub fn SymbolNavigationAnchor::surface_generation(Self) -> Int
+
+pub(all) enum SymbolNavigationCommand {
+ RevealDefinition
+ PeekDefinition
+ GoToReferences
+ PeekReferences
+} derive(Eq, @debug.Debug)
+pub fn SymbolNavigationCommand::from_id(String) -> Self?
+pub fn SymbolNavigationCommand::id(Self) -> String
+pub fn SymbolNavigationCommand::is_available(Self, @languages.LanguageHandle, @model.TextModel, Bool) -> Bool
+pub fn SymbolNavigationCommand::keybinding_label(Self) -> String?
+pub fn SymbolNavigationCommand::title(Self) -> String
+
+// Type aliases
+
+// Traits
diff --git a/editor/internal/viewer/contrib/goto_symbol/symbol_navigation.mbt b/editor/internal/viewer/contrib/goto_symbol/symbol_navigation.mbt
new file mode 100644
index 000000000..146162b88
--- /dev/null
+++ b/editor/internal/viewer/contrib/goto_symbol/symbol_navigation.mbt
@@ -0,0 +1,661 @@
+///|
+/// Immutable semantic origin for one symbol-navigation request. Surface
+/// adapters reduce DOM/cursor input to this value before shared actions see
+/// it; the original TextModel remains the only provider input.
+pub struct SymbolNavigationAnchor {
+ priv model : @model.TextModel
+ priv position : @base_common.Position
+ priv range : @base_common.Range
+ priv model_identity : Int
+ priv model_version_id : Int
+ priv surface_generation : Int
+}
+
+///|
+pub fn SymbolNavigationAnchor::from_model(
+ model : @model.TextModel,
+ position : @base_common.Position,
+ range : @base_common.Range,
+ surface_generation : Int,
+) -> SymbolNavigationAnchor {
+ let position = model.validate_position(position)
+ {
+ model,
+ position,
+ range: model.validate_range(range),
+ model_identity: model.identity(),
+ model_version_id: model.get_version_id(),
+ surface_generation,
+ }
+}
+
+///|
+pub fn SymbolNavigationAnchor::model(
+ self : SymbolNavigationAnchor,
+) -> @model.TextModel {
+ self.model
+}
+
+///|
+pub fn SymbolNavigationAnchor::position(
+ self : SymbolNavigationAnchor,
+) -> @base_common.Position {
+ self.position
+}
+
+///|
+pub fn SymbolNavigationAnchor::range(
+ self : SymbolNavigationAnchor,
+) -> @base_common.Range {
+ self.range
+}
+
+///|
+pub fn SymbolNavigationAnchor::surface_generation(
+ self : SymbolNavigationAnchor,
+) -> Int {
+ self.surface_generation
+}
+
+///|
+/// Snapshot-only half of request freshness. The surface host separately
+/// validates attachment/projection identity through `is_anchor_current`.
+pub fn SymbolNavigationAnchor::model_is_current(
+ self : SymbolNavigationAnchor,
+) -> Bool {
+ !self.model.is_disposed() &&
+ self.model.identity() == self.model_identity &&
+ self.model.get_version_id() == self.model_version_id
+}
+
+///|
+pub fn SymbolNavigationAnchor::same_snapshot(
+ self : SymbolNavigationAnchor,
+ other : SymbolNavigationAnchor,
+) -> Bool {
+ self.model_identity == other.model_identity &&
+ self.model_version_id == other.model_version_id &&
+ self.surface_generation == other.surface_generation &&
+ self.position == other.position &&
+ self.range == other.range
+}
+
+///|
+/// Shared command identity and presentation. Surface adapters render these
+/// descriptors in their native command/menu infrastructure; command ids,
+/// labels, keybinding labels, and provider enablement stay centralized here.
+pub(all) enum SymbolNavigationCommand {
+ RevealDefinition
+ PeekDefinition
+ GoToReferences
+ PeekReferences
+} derive(Eq, Debug)
+
+///|
+pub fn SymbolNavigationCommand::id(self : SymbolNavigationCommand) -> String {
+ match self {
+ RevealDefinition => "editor.action.revealDefinition"
+ PeekDefinition => "editor.action.peekDefinition"
+ GoToReferences => "editor.action.goToReferences"
+ PeekReferences => "editor.action.referenceSearch.trigger"
+ }
+}
+
+///|
+pub fn SymbolNavigationCommand::title(self : SymbolNavigationCommand) -> String {
+ match self {
+ RevealDefinition => "Go to Definition"
+ PeekDefinition => "Peek Definition"
+ GoToReferences => "Go to References"
+ PeekReferences => "Peek References"
+ }
+}
+
+///|
+pub fn SymbolNavigationCommand::keybinding_label(
+ self : SymbolNavigationCommand,
+) -> String? {
+ match self {
+ RevealDefinition => Some("F12")
+ PeekDefinition =>
+ Some(if @base_common.is_macintosh { "\u{2325}F12" } else { "Alt+F12" })
+ GoToReferences =>
+ Some(if @base_common.is_macintosh { "\u{21e7}F12" } else { "Shift+F12" })
+ PeekReferences => None
+ }
+}
+
+///|
+pub fn SymbolNavigationCommand::from_id(
+ id : String,
+) -> SymbolNavigationCommand? {
+ match id {
+ "editor.action.revealDefinition" => Some(RevealDefinition)
+ "editor.action.peekDefinition" => Some(PeekDefinition)
+ "editor.action.goToReferences" => Some(GoToReferences)
+ "editor.action.referenceSearch.trigger" => Some(PeekReferences)
+ _ => None
+ }
+}
+
+///|
+pub fn SymbolNavigationCommand::is_available(
+ self : SymbolNavigationCommand,
+ languages : @languages.LanguageHandle,
+ model : @model.TextModel,
+ peek_available : Bool,
+) -> Bool {
+ match self {
+ RevealDefinition => languages.has_definition_provider(model)
+ PeekDefinition => peek_available && languages.has_definition_provider(model)
+ GoToReferences => languages.has_references_provider(model)
+ PeekReferences => peek_available && languages.has_references_provider(model)
+ }
+}
+
+///|
+pub fn symbol_navigation_context_commands(
+ languages : @languages.LanguageHandle,
+ model : @model.TextModel,
+ peek_available : Bool,
+) -> Array[SymbolNavigationCommand] {
+ [RevealDefinition, PeekDefinition, GoToReferences, PeekReferences].filter(command => {
+ command.is_available(languages, model, peek_available)
+ })
+}
+
+///|
+pub(all) struct SymbolNavigationActionConfig {
+ open_to_side : Bool
+ open_in_peek : Bool
+ mute_message : Bool
+}
+
+///|
+pub fn SymbolNavigationActionConfig::goto() -> SymbolNavigationActionConfig {
+ { open_to_side: false, open_in_peek: false, mute_message: false }
+}
+
+///|
+pub fn SymbolNavigationActionConfig::peek() -> SymbolNavigationActionConfig {
+ { open_to_side: false, open_in_peek: true, mute_message: false }
+}
+
+///|
+pub fn SymbolNavigationActionConfig::link() -> SymbolNavigationActionConfig {
+ { open_to_side: false, open_in_peek: false, mute_message: true }
+}
+
+///|
+/// Narrow surface contract used by DefinitionAction and ReferencesAction.
+/// It intentionally contains no CodeEditorWidget, MarkdownDocumentView, DOM,
+/// ViewZone, or overlay type.
+pub(all) struct SymbolNavigationActionHost {
+ prepare_navigation : () -> Unit
+ is_anchor_current : (SymbolNavigationAnchor) -> Bool
+ open_location : async (
+ SymbolNavigationAnchor,
+ @language.Location,
+ @navigation_api.OpenLocationMode,
+ String,
+ ) -> Bool noraise
+ open_in_peek : (
+ SymbolNavigationAnchor,
+ @references.ReferencesPeekMode,
+ Array[@language.Location],
+ ) -> Bool
+ show_message : (SymbolNavigationAnchor, String) -> Unit
+}
+
+///|
+priv enum SymbolNavigationQueryKind {
+ DefinitionQuery
+ ReferencesQuery
+}
+
+///|
+/// Shared cancellation, provider-query, normalization, and result-policy
+/// owner. DefinitionAction and ReferencesAction are typed views over this one
+/// mutually-cancelling request stream.
+pub struct SymbolNavigationAction {
+ priv languages : @languages.LanguageHandle
+ priv host : SymbolNavigationActionHost
+ priv run_provider_tasks : async (Array[async () -> Unit]) -> Unit
+ priv mut generation : Int
+ priv mut source : @language.CancellationTokenSource?
+ priv mut disposed : Bool
+}
+
+///|
+pub fn SymbolNavigationAction::SymbolNavigationAction(
+ languages : @languages.LanguageHandle,
+ host : SymbolNavigationActionHost,
+ run_provider_tasks : async (Array[async () -> Unit]) -> Unit,
+) -> SymbolNavigationAction {
+ {
+ languages,
+ host,
+ run_provider_tasks,
+ generation: 0,
+ source: None,
+ disposed: false,
+ }
+}
+
+///|
+pub fn SymbolNavigationAction::cancel(self : SymbolNavigationAction) -> Unit {
+ self.generation += 1
+ let source = self.source
+ self.source = None
+ match source {
+ Some(source) => source.dispose(cancel=true)
+ None => ()
+ }
+}
+
+///|
+pub fn SymbolNavigationAction::dispose(self : SymbolNavigationAction) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.cancel()
+}
+
+///|
+fn SymbolNavigationAction::request_is_current(
+ self : SymbolNavigationAction,
+ generation : Int,
+ source : @language.CancellationTokenSource,
+ anchor : SymbolNavigationAnchor,
+) -> Bool {
+ !self.disposed &&
+ self.generation == generation &&
+ self.source is Some(current) &&
+ physical_equal(current, source) &&
+ !source.token().is_cancellation_requested() &&
+ anchor.model_is_current() &&
+ (self.host.is_anchor_current)(anchor)
+}
+
+///|
+fn SymbolNavigationAction::result_is_current(
+ self : SymbolNavigationAction,
+ generation : Int,
+ anchor : SymbolNavigationAnchor,
+) -> Bool {
+ !self.disposed &&
+ self.generation == generation &&
+ anchor.model_is_current() &&
+ (self.host.is_anchor_current)(anchor)
+}
+
+///|
+fn SymbolNavigationAction::retire_source(
+ self : SymbolNavigationAction,
+ source : @language.CancellationTokenSource,
+ cancel~ : Bool,
+) -> Unit {
+ match self.source {
+ Some(current) if physical_equal(current, source) => {
+ self.source = None
+ source.dispose(cancel~)
+ }
+ _ => source.dispose(cancel~)
+ }
+}
+
+///|
+async fn SymbolNavigationAction::query(
+ self : SymbolNavigationAction,
+ kind : SymbolNavigationQueryKind,
+ anchor : SymbolNavigationAnchor,
+ token : @language.CancellationToken,
+) -> Array[@language.Location] noraise {
+ let model = anchor.model()
+ let offset = model.get_offset_at(anchor.position())
+ match kind {
+ DefinitionQuery =>
+ self.languages.definition_at(
+ model,
+ offset,
+ token~,
+ run_tasks=self.run_provider_tasks,
+ )
+ ReferencesQuery =>
+ self.languages.references_at(
+ model,
+ offset,
+ token~,
+ run_tasks=self.run_provider_tasks,
+ )
+ }
+}
+
+///|
+fn normalize_symbol_locations(
+ locations : Array[@language.Location],
+) -> Array[@language.Location] {
+ let normalized : Array[@language.Location] = []
+ for candidate in locations {
+ if !normalized.any(existing => {
+ existing.uri.to_string() == candidate.uri.to_string() &&
+ existing.range == candidate.range
+ }) {
+ normalized.push(candidate)
+ }
+ }
+ normalized
+}
+
+///|
+/// Shared provider-query owner for modifier-hover Definition links. Surfaces
+/// retain only hit testing and projected-decoration work.
+pub struct DefinitionLinkResolver {
+ priv languages : @languages.LanguageHandle
+ priv is_anchor_current : (SymbolNavigationAnchor) -> Bool
+ priv run_provider_tasks : async (Array[async () -> Unit]) -> Unit
+ priv mut generation : Int
+ priv mut source : @language.CancellationTokenSource?
+ priv mut disposed : Bool
+}
+
+///|
+pub fn DefinitionLinkResolver::DefinitionLinkResolver(
+ languages : @languages.LanguageHandle,
+ is_anchor_current~ : (SymbolNavigationAnchor) -> Bool,
+ run_provider_tasks : async (Array[async () -> Unit]) -> Unit,
+) -> DefinitionLinkResolver {
+ {
+ languages,
+ is_anchor_current,
+ run_provider_tasks,
+ generation: 0,
+ source: None,
+ disposed: false,
+ }
+}
+
+///|
+pub fn DefinitionLinkResolver::cancel(self : DefinitionLinkResolver) -> Unit {
+ self.generation += 1
+ let source = self.source
+ self.source = None
+ match source {
+ Some(source) => source.dispose(cancel=true)
+ None => ()
+ }
+}
+
+///|
+pub fn DefinitionLinkResolver::begin(
+ self : DefinitionLinkResolver,
+ anchor : SymbolNavigationAnchor,
+) -> Int? {
+ guard !self.disposed &&
+ anchor.model_is_current() &&
+ (self.is_anchor_current)(anchor) else {
+ return None
+ }
+ self.cancel()
+ self.generation += 1
+ Some(self.generation)
+}
+
+///|
+pub async fn DefinitionLinkResolver::resolve_reserved(
+ self : DefinitionLinkResolver,
+ anchor : SymbolNavigationAnchor,
+ generation : Int,
+) -> Array[@language.Location] noraise {
+ guard !self.disposed &&
+ self.generation == generation &&
+ anchor.model_is_current() &&
+ (self.is_anchor_current)(anchor) else {
+ return []
+ }
+ let source = @language.CancellationTokenSource()
+ self.source = Some(source)
+ let model = anchor.model()
+ let locations = self.languages.definition_at(
+ model,
+ model.get_offset_at(anchor.position()),
+ token=source.token(),
+ run_tasks=self.run_provider_tasks,
+ )
+ let current = !self.disposed &&
+ self.generation == generation &&
+ self.source is Some(current_source) &&
+ physical_equal(current_source, source) &&
+ !source.token().is_cancellation_requested() &&
+ anchor.model_is_current() &&
+ (self.is_anchor_current)(anchor)
+ if !current {
+ match self.source {
+ Some(current_source) if physical_equal(current_source, source) =>
+ self.source = None
+ _ => ()
+ }
+ source.dispose(cancel=true)
+ return []
+ }
+ self.source = None
+ source.dispose()
+ normalize_symbol_locations(locations)
+}
+
+///|
+pub fn DefinitionLinkResolver::dispose(self : DefinitionLinkResolver) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.cancel()
+}
+
+///|
+fn symbol_navigation_no_result_message(
+ kind : SymbolNavigationQueryKind,
+ anchor : SymbolNavigationAnchor,
+) -> String {
+ let word = anchor.model().get_word_at_position(anchor.position())
+ match (kind, word) {
+ (DefinitionQuery, Some(word)) if !word.word.is_empty() =>
+ "No definition found for '\{word.word}'"
+ (ReferencesQuery, Some(word)) if !word.word.is_empty() =>
+ "No references found for '\{word.word}'"
+ (DefinitionQuery, _) => "No definition found"
+ (ReferencesQuery, _) => "No references found"
+ }
+}
+
+///|
+fn symbol_navigation_open_failure_message(
+ kind : SymbolNavigationQueryKind,
+) -> String {
+ match kind {
+ DefinitionQuery => "Unable to open definition"
+ ReferencesQuery => "Unable to open reference"
+ }
+}
+
+///|
+fn symbol_navigation_open_source(kind : SymbolNavigationQueryKind) -> String {
+ match kind {
+ DefinitionQuery => "gotoDefinition"
+ ReferencesQuery => "showReferences"
+ }
+}
+
+///|
+fn symbol_navigation_peek_mode(
+ kind : SymbolNavigationQueryKind,
+) -> @references.ReferencesPeekMode {
+ match kind {
+ DefinitionQuery => Definitions
+ ReferencesQuery => References
+ }
+}
+
+///|
+fn SymbolNavigationAction::begin(
+ self : SymbolNavigationAction,
+ anchor : SymbolNavigationAnchor,
+) -> Int? {
+ guard !self.disposed && anchor.model_is_current() else { return None }
+ self.cancel()
+ self.generation += 1
+ let generation = self.generation
+ (self.host.prepare_navigation)()
+ if self.result_is_current(generation, anchor) {
+ Some(generation)
+ } else {
+ None
+ }
+}
+
+///|
+async fn SymbolNavigationAction::run_reserved(
+ self : SymbolNavigationAction,
+ kind : SymbolNavigationQueryKind,
+ anchor : SymbolNavigationAnchor,
+ config : SymbolNavigationActionConfig,
+ generation : Int,
+) -> Unit noraise {
+ guard self.result_is_current(generation, anchor) else { return }
+ let source = @language.CancellationTokenSource()
+ self.source = Some(source)
+ let locations = self.query(kind, anchor, source.token())
+ if !self.request_is_current(generation, source, anchor) {
+ self.retire_source(source, cancel=true)
+ return
+ }
+ self.source = None
+ source.dispose()
+ let locations = normalize_symbol_locations(locations)
+ if locations.is_empty() {
+ if !config.mute_message && self.result_is_current(generation, anchor) {
+ (self.host.show_message)(
+ anchor,
+ symbol_navigation_no_result_message(kind, anchor),
+ )
+ }
+ return
+ }
+ let wants_peek = config.open_in_peek || locations.length() > 1
+ if wants_peek && self.result_is_current(generation, anchor) {
+ let opened = (self.host.open_in_peek)(
+ anchor,
+ symbol_navigation_peek_mode(kind),
+ locations,
+ )
+ if opened {
+ return
+ }
+ if config.open_in_peek {
+ if !config.mute_message && self.result_is_current(generation, anchor) {
+ (self.host.show_message)(anchor, "No preview available")
+ }
+ return
+ }
+ }
+ guard self.result_is_current(generation, anchor) else { return }
+ let accepted = (self.host.open_location)(
+ anchor,
+ locations[0],
+ if config.open_to_side {
+ Side
+ } else {
+ Current
+ },
+ symbol_navigation_open_source(kind),
+ )
+ if !accepted &&
+ !config.mute_message &&
+ self.result_is_current(generation, anchor) {
+ (self.host.show_message)(
+ anchor,
+ symbol_navigation_open_failure_message(kind),
+ )
+ }
+}
+
+///|
+pub struct DefinitionAction {
+ priv action : SymbolNavigationAction
+}
+
+///|
+pub fn DefinitionAction::DefinitionAction(
+ action : SymbolNavigationAction,
+) -> DefinitionAction {
+ { action, }
+}
+
+///|
+pub fn DefinitionAction::begin(
+ self : DefinitionAction,
+ anchor : SymbolNavigationAnchor,
+) -> Int? {
+ self.action.begin(anchor)
+}
+
+///|
+pub async fn DefinitionAction::run_reserved(
+ self : DefinitionAction,
+ anchor : SymbolNavigationAnchor,
+ config : SymbolNavigationActionConfig,
+ generation : Int,
+) -> Unit noraise {
+ self.action.run_reserved(DefinitionQuery, anchor, config, generation)
+}
+
+///|
+pub async fn DefinitionAction::run(
+ self : DefinitionAction,
+ anchor : SymbolNavigationAnchor,
+ config : SymbolNavigationActionConfig,
+) -> Unit noraise {
+ guard self.begin(anchor) is Some(generation) else { return }
+ self.run_reserved(anchor, config, generation)
+}
+
+///|
+pub struct ReferencesAction {
+ priv action : SymbolNavigationAction
+}
+
+///|
+pub fn ReferencesAction::ReferencesAction(
+ action : SymbolNavigationAction,
+) -> ReferencesAction {
+ { action, }
+}
+
+///|
+pub fn ReferencesAction::begin(
+ self : ReferencesAction,
+ anchor : SymbolNavigationAnchor,
+) -> Int? {
+ self.action.begin(anchor)
+}
+
+///|
+pub async fn ReferencesAction::run_reserved(
+ self : ReferencesAction,
+ anchor : SymbolNavigationAnchor,
+ config : SymbolNavigationActionConfig,
+ generation : Int,
+) -> Unit noraise {
+ self.action.run_reserved(ReferencesQuery, anchor, config, generation)
+}
+
+///|
+pub async fn ReferencesAction::run(
+ self : ReferencesAction,
+ anchor : SymbolNavigationAnchor,
+ config : SymbolNavigationActionConfig,
+) -> Unit noraise {
+ guard self.begin(anchor) is Some(generation) else { return }
+ self.run_reserved(anchor, config, generation)
+}
diff --git a/editor/internal/viewer/contrib/goto_symbol/symbol_navigation_wbtest.mbt b/editor/internal/viewer/contrib/goto_symbol/symbol_navigation_wbtest.mbt
new file mode 100644
index 000000000..06c0589f1
--- /dev/null
+++ b/editor/internal/viewer/contrib/goto_symbol/symbol_navigation_wbtest.mbt
@@ -0,0 +1,440 @@
+///|
+priv struct SymbolNavigationTestDefinitionProvider {
+ locations : Array[@language.Location]
+ calls : Ref[Int]
+ tokens : Array[@language.CancellationToken]
+ started : @async.Semaphore?
+ release : @async.Semaphore?
+}
+
+///|
+impl @language.DefinitionProvider for SymbolNavigationTestDefinitionProvider with fn provide_definition(
+ self,
+ _model,
+ _position,
+ token,
+) {
+ self.calls.val += 1
+ self.tokens.push(token)
+ match self.started {
+ Some(started) => started.release()
+ None => ()
+ }
+ match self.release {
+ Some(release) => release.acquire() catch { _ => return [] }
+ None => ()
+ }
+ self.locations.copy()
+}
+
+///|
+priv struct SymbolNavigationTestReferencesProvider {
+ locations : Array[@language.Location]
+ calls : Ref[Int]
+}
+
+///|
+impl @language.ReferencesProvider for SymbolNavigationTestReferencesProvider with fn provide_references(
+ self,
+ _model,
+ _position,
+ _token,
+) {
+ self.calls.val += 1
+ self.locations.copy()
+}
+
+///|
+priv struct SymbolNavigationTestHost {
+ model : @model.TextModel
+ surface_generation : Ref[Int]
+ prepare_calls : Ref[Int]
+ opened : Array[@language.Location]
+ open_modes : Array[@navigation_api.OpenLocationMode]
+ peek_modes : Array[@references.ReferencesPeekMode]
+ peek_results : Array[Array[@language.Location]]
+ messages : Array[String]
+ accept_peek : Ref[Bool]
+}
+
+///|
+fn SymbolNavigationTestHost::host(
+ self : SymbolNavigationTestHost,
+) -> SymbolNavigationActionHost {
+ {
+ prepare_navigation: () => self.prepare_calls.val += 1,
+ is_anchor_current: anchor => {
+ anchor.model().same(self.model) &&
+ anchor.surface_generation() == self.surface_generation.val
+ },
+ open_location: (_anchor, location, mode, _source) => {
+ self.opened.push(location)
+ self.open_modes.push(mode)
+ true
+ },
+ open_in_peek: (_anchor, mode, locations) => {
+ self.peek_modes.push(mode)
+ self.peek_results.push(locations.copy())
+ self.accept_peek.val
+ },
+ show_message: (_anchor, message) => self.messages.push(message),
+ }
+}
+
+///|
+fn symbol_navigation_test_model() -> @model.TextModel {
+ TextModel(
+ try! @base_common.Uri::parse("file:///symbol-navigation.mbt"),
+ "symbol-navigation.mbt",
+ "moonbit",
+ 1,
+ "revision-1",
+ "let source = target\n",
+ )
+}
+
+///|
+fn symbol_navigation_test_anchor(
+ model : @model.TextModel,
+ surface_generation : Int,
+) -> SymbolNavigationAnchor {
+ SymbolNavigationAnchor::from_model(
+ model,
+ Position(1, 14),
+ Range(1, 14, 1, 20),
+ surface_generation,
+ )
+}
+
+///|
+fn symbol_navigation_test_host(
+ model : @model.TextModel,
+ surface_generation : Ref[Int],
+) -> SymbolNavigationTestHost {
+ {
+ model,
+ surface_generation,
+ prepare_calls: Ref(0),
+ opened: [],
+ open_modes: [],
+ peek_modes: [],
+ peek_results: [],
+ messages: [],
+ accept_peek: Ref(true),
+ }
+}
+
+///|
+async fn symbol_navigation_run_test_tasks(
+ tasks : Array[async () -> Unit],
+) -> Unit {
+ for task in tasks {
+ task()
+ }
+}
+
+///|
+test "action cancellation callbacks cannot clobber a replacement source" {
+ let model = symbol_navigation_test_model()
+ defer model.dispose()
+ let surface_generation = Ref(1)
+ let host = symbol_navigation_test_host(model, surface_generation)
+ let languages = @languages.Languages()
+ let action = SymbolNavigationAction(
+ languages.language_handle(@log.LogService().log_handle()),
+ host.host(),
+ symbol_navigation_run_test_tasks,
+ )
+ let old_source = @language.CancellationTokenSource()
+ let replacement_source = @language.CancellationTokenSource()
+ old_source
+ .token()
+ .on_cancellation_requested(() => action.source = Some(replacement_source))
+ |> ignore
+ action.source = Some(old_source)
+ action.cancel()
+ guard action.source is Some(current_source) else {
+ fail("cancellation clobbered the replacement source")
+ }
+ assert_true(physical_equal(current_source, replacement_source))
+ action.source = None
+ replacement_source.dispose(cancel=true)
+ action.dispose()
+}
+
+///|
+async test "shared DefinitionAction applies zero one and multi-result policy once" {
+ let model = symbol_navigation_test_model()
+ defer model.dispose()
+ let surface_generation = Ref(7)
+ let host = symbol_navigation_test_host(model, surface_generation)
+ let calls = Ref(0)
+ let first : @language.Location = {
+ uri: @base_common.Uri::file("/first.mbt"),
+ range: Range(3, 2, 3, 8),
+ }
+ let second : @language.Location = {
+ uri: @base_common.Uri::file("/second.mbt"),
+ range: Range(9, 4, 9, 10),
+ }
+ let languages = @languages.Languages()
+ let registration = languages.register_definition_provider(
+ LanguageId("moonbit"),
+ SymbolNavigationTestDefinitionProvider::{
+ locations: [],
+ calls,
+ tokens: [],
+ started: None,
+ release: None,
+ },
+ )
+ let action = SymbolNavigationAction(
+ languages.language_handle(@log.LogService().log_handle()),
+ host.host(),
+ symbol_navigation_run_test_tasks,
+ )
+ let definition = DefinitionAction(action)
+ let anchor = symbol_navigation_test_anchor(model, surface_generation.val)
+
+ definition.run(anchor, SymbolNavigationActionConfig::goto())
+ assert_eq(calls.val, 1)
+ assert_eq(host.opened.length(), 0)
+ assert_eq(host.peek_results.length(), 0)
+ assert_eq(host.messages, ["No definition found for 'target'"])
+
+ registration.dispose()
+ let single_calls = Ref(0)
+ let single = languages.register_definition_provider(LanguageId("moonbit"), SymbolNavigationTestDefinitionProvider::{
+ locations: [first],
+ calls: single_calls,
+ tokens: [],
+ started: None,
+ release: None,
+ })
+ definition.run(anchor, SymbolNavigationActionConfig::goto())
+ assert_eq(single_calls.val, 1)
+ assert_eq(host.opened.length(), 1)
+ assert_eq(host.opened[0].uri.to_string(), first.uri.to_string())
+ assert_eq(host.opened[0].range, first.range)
+ assert_eq(host.open_modes, [Current])
+ assert_eq(host.peek_results.length(), 0)
+
+ single.dispose()
+ let multiple_calls = Ref(0)
+ let multiple = languages.register_definition_provider(LanguageId("moonbit"), SymbolNavigationTestDefinitionProvider::{
+ locations: [first, first, second],
+ calls: multiple_calls,
+ tokens: [],
+ started: None,
+ release: None,
+ })
+ definition.run(anchor, SymbolNavigationActionConfig::goto())
+ assert_eq(multiple_calls.val, 1)
+ assert_eq(host.opened.length(), 1)
+ assert_eq(host.peek_modes, [Definitions])
+ assert_eq(host.peek_results.length(), 1)
+ assert_eq(host.peek_results[0].length(), 2)
+ assert_eq(host.peek_results[0][0].uri.to_string(), first.uri.to_string())
+ assert_eq(host.peek_results[0][0].range, first.range)
+ assert_eq(host.peek_results[0][1].uri.to_string(), second.uri.to_string())
+ assert_eq(host.peek_results[0][1].range, second.range)
+
+ multiple.dispose()
+ action.dispose()
+}
+
+///|
+async test "forced Peek and ReferencesAction share policy but query only their provider" {
+ let model = symbol_navigation_test_model()
+ defer model.dispose()
+ let surface_generation = Ref(1)
+ let host = symbol_navigation_test_host(model, surface_generation)
+ let definition_calls = Ref(0)
+ let references_calls = Ref(0)
+ let location : @language.Location = {
+ uri: model.uri,
+ range: Range(1, 5, 1, 11),
+ }
+ let languages = @languages.Languages()
+ let definition_registration = languages.register_definition_provider(
+ LanguageId("moonbit"),
+ SymbolNavigationTestDefinitionProvider::{
+ locations: [location],
+ calls: definition_calls,
+ tokens: [],
+ started: None,
+ release: None,
+ },
+ )
+ let references_registration = languages.register_references_provider(
+ LanguageId("moonbit"),
+ SymbolNavigationTestReferencesProvider::{
+ locations: [location],
+ calls: references_calls,
+ },
+ )
+ let action = SymbolNavigationAction(
+ languages.language_handle(@log.LogService().log_handle()),
+ host.host(),
+ symbol_navigation_run_test_tasks,
+ )
+ let anchor = symbol_navigation_test_anchor(model, surface_generation.val)
+ DefinitionAction(action).run(anchor, SymbolNavigationActionConfig::peek())
+ assert_eq(definition_calls.val, 1)
+ assert_eq(references_calls.val, 0)
+ assert_eq(host.peek_modes, [Definitions])
+ assert_eq(host.opened.length(), 0)
+
+ ReferencesAction(action).run(anchor, SymbolNavigationActionConfig::goto())
+ assert_eq(definition_calls.val, 1)
+ assert_eq(references_calls.val, 1)
+ assert_eq(host.opened.length(), 1)
+ assert_eq(host.opened[0].uri.to_string(), location.uri.to_string())
+ assert_eq(host.opened[0].range, location.range)
+
+ host.accept_peek.val = false
+ ReferencesAction(action).run(anchor, SymbolNavigationActionConfig::peek())
+ assert_eq(references_calls.val, 2)
+ assert_eq(host.opened.length(), 1)
+ assert_eq(host.messages.last(), Some("No preview available"))
+
+ action.dispose()
+ definition_registration.dispose()
+ references_registration.dispose()
+}
+
+///|
+async test "cancellation model version and surface generation suppress stale results" {
+ let model = symbol_navigation_test_model()
+ defer model.dispose()
+ let surface_generation = Ref(3)
+ let host = symbol_navigation_test_host(model, surface_generation)
+ let started = @async.Semaphore(1, initial_value=0)
+ let release = @async.Semaphore(1, initial_value=0)
+ let calls = Ref(0)
+ let tokens : Array[@language.CancellationToken] = []
+ let target : @language.Location = {
+ uri: @base_common.Uri::file("/target.mbt"),
+ range: Range(4, 2, 4, 8),
+ }
+ let languages = @languages.Languages()
+ let registration = languages.register_definition_provider(
+ LanguageId("moonbit"),
+ SymbolNavigationTestDefinitionProvider::{
+ locations: [target],
+ calls,
+ tokens,
+ started: Some(started),
+ release: Some(release),
+ },
+ )
+ let action = SymbolNavigationAction(
+ languages.language_handle(@log.LogService().log_handle()),
+ host.host(),
+ symbol_navigation_run_test_tasks,
+ )
+ let definition = DefinitionAction(action)
+ let anchor = symbol_navigation_test_anchor(model, surface_generation.val)
+ @async.with_task_group() <| group => {
+ let pending = group.spawn(() => {
+ definition.run(anchor, SymbolNavigationActionConfig::goto())
+ })
+ started.acquire()
+ action.cancel()
+ release.release()
+ pending.wait()
+ }
+ assert_eq(calls.val, 1)
+ assert_true(tokens[0].is_cancellation_requested())
+ assert_eq(host.opened.length(), 0)
+
+ let started_version = @async.Semaphore(1, initial_value=0)
+ let release_version = @async.Semaphore(1, initial_value=0)
+ registration.dispose()
+ let version_registration = languages.register_definition_provider(
+ LanguageId("moonbit"),
+ SymbolNavigationTestDefinitionProvider::{
+ locations: [target],
+ calls,
+ tokens,
+ started: Some(started_version),
+ release: Some(release_version),
+ },
+ )
+ let version_anchor = symbol_navigation_test_anchor(
+ model,
+ surface_generation.val,
+ )
+ @async.with_task_group() <| group => {
+ let pending = group.spawn(() => {
+ definition.run(version_anchor, SymbolNavigationActionConfig::goto())
+ })
+ started_version.acquire()
+ model.set_value("let source = changed\n")
+ release_version.release()
+ pending.wait()
+ }
+ assert_eq(host.opened.length(), 0)
+ version_registration.dispose()
+
+ let started_surface = @async.Semaphore(1, initial_value=0)
+ let release_surface = @async.Semaphore(1, initial_value=0)
+ let surface_registration = languages.register_definition_provider(
+ LanguageId("moonbit"),
+ SymbolNavigationTestDefinitionProvider::{
+ locations: [target],
+ calls,
+ tokens,
+ started: Some(started_surface),
+ release: Some(release_surface),
+ },
+ )
+ let surface_anchor = symbol_navigation_test_anchor(
+ model,
+ surface_generation.val,
+ )
+ @async.with_task_group() <| group => {
+ let pending = group.spawn(() => {
+ definition.run(surface_anchor, SymbolNavigationActionConfig::goto())
+ })
+ started_surface.acquire()
+ surface_generation.val += 1
+ release_surface.release()
+ pending.wait()
+ }
+ assert_eq(host.opened.length(), 0)
+
+ surface_registration.dispose()
+ action.dispose()
+}
+
+///|
+test "shared command descriptors own ids labels keybindings and enablement" {
+ let model = symbol_navigation_test_model()
+ defer model.dispose()
+ let languages = @languages.Languages()
+ let handle = languages.language_handle(@log.LogService().log_handle())
+ assert_eq(symbol_navigation_context_commands(handle, model, true), [])
+ let definition = languages.register_definition_provider(
+ LanguageId("moonbit"),
+ SymbolNavigationTestDefinitionProvider::{
+ locations: [],
+ calls: Ref(0),
+ tokens: [],
+ started: None,
+ release: None,
+ },
+ )
+ assert_eq(symbol_navigation_context_commands(handle, model, false), [
+ RevealDefinition,
+ ])
+ assert_eq(symbol_navigation_context_commands(handle, model, true), [
+ RevealDefinition,
+ PeekDefinition,
+ ])
+ assert_eq(RevealDefinition.id(), "editor.action.revealDefinition")
+ assert_eq(PeekDefinition.title(), "Peek Definition")
+ assert_eq(GoToReferences.id(), "editor.action.goToReferences")
+ assert_eq(PeekReferences.id(), "editor.action.referenceSearch.trigger")
+ definition.dispose()
+}
diff --git a/editor/internal/viewer/contrib/quick_diff/browser/pkg.generated.mbti b/editor/internal/viewer/contrib/quick_diff/browser/pkg.generated.mbti
index b9356f381..d640cedc0 100644
--- a/editor/internal/viewer/contrib/quick_diff/browser/pkg.generated.mbti
+++ b/editor/internal/viewer/contrib/quick_diff/browser/pkg.generated.mbti
@@ -9,7 +9,7 @@ import {
// Values
pub fn changes_to_decorations(Array[@diff.DetailedLineRangeMapping]) -> Array[@model.ModelDeltaDecoration]
-pub fn compute_changes(Array[String], Array[String], ignore_trim_whitespace? : Bool, max_computation_time_ms? : Int) -> Array[@diff.DetailedLineRangeMapping]
+pub fn compute_changes(Array[String], Array[String], ignore_trim_whitespace? : Bool) -> Array[@diff.DetailedLineRangeMapping]
// Errors
diff --git a/editor/internal/viewer/contrib/quick_diff/browser/quick_diff_model.mbt b/editor/internal/viewer/contrib/quick_diff/browser/quick_diff_model.mbt
index b4a8d93ed..47f535b94 100644
--- a/editor/internal/viewer/contrib/quick_diff/browser/quick_diff_model.mbt
+++ b/editor/internal/viewer/contrib/quick_diff/browser/quick_diff_model.mbt
@@ -17,18 +17,18 @@
///|
/// `QuickDiffModel._diff` (`quickDiffModel.ts:348`) reduced: computes the
/// line-level changes between the original document and the modified one.
-/// Defaults mirror the source's effective configuration: a 1000ms budget
-/// (`quickDiffModel.ts:43`) and `scm.diffDecorationsIgnoreTrimWhitespace`'s
-/// `'false'` default (`scm.contribution.ts:239`).
+/// The trim-whitespace default mirrors
+/// `scm.diffDecorationsIgnoreTrimWhitespace`'s `'false'` default
+/// (`scm.contribution.ts:239`). The local Core provider is synchronous and
+/// has no wall-clock budget option.
pub fn compute_changes(
original_lines : Array[String],
modified_lines : Array[String],
ignore_trim_whitespace? : Bool = false,
- max_computation_time_ms? : Int = 1000,
) -> Array[@diff.DetailedLineRangeMapping] {
- @diff.get_default().compute_diff(original_lines, modified_lines, {
- ignore_trim_whitespace,
- max_computation_time_ms,
- compute_moves: false,
- }).changes
+ @diff.CoreDocumentDiffProvider().compute_diff(
+ TextSnapshot(original_lines.join("\n")),
+ TextSnapshot(modified_lines.join("\n")),
+ { ignore_trim_whitespace, },
+ ).changes
}
diff --git a/editor/internal/viewer/contrib/quick_diff/common/README.mbt.md b/editor/internal/viewer/contrib/quick_diff/common/README.mbt.md
index a50624980..cce237951 100644
--- a/editor/internal/viewer/contrib/quick_diff/common/README.mbt.md
+++ b/editor/internal/viewer/contrib/quick_diff/common/README.mbt.md
@@ -92,26 +92,21 @@ addition, an empty modified is a deletion, and anything else is a modification.
```mbt check
///|
test "an empty side decides between Add, Delete, and Modify" {
- let options : @diff.LinesDiffComputerOptions = {
- ignore_trim_whitespace: false,
- max_computation_time_ms: 0,
- compute_moves: false,
- }
- let added = @diff.get_default().compute_diff(
- ["a", "c"],
- ["a", "b", "c"],
- options,
- ).changes[0]
- let deleted = @diff.get_default().compute_diff(
- ["a", "b", "c"],
- ["a", "c"],
- options,
- ).changes[0]
- let modified = @diff.get_default().compute_diff(
- ["a", "old", "c"],
- ["a", "new", "c"],
- options,
- ).changes[0]
+ let added = @diff.DetailedLineRangeMapping(
+ LineRange(2, 2),
+ LineRange(2, 3),
+ None,
+ )
+ let deleted = @diff.DetailedLineRangeMapping(
+ LineRange(2, 3),
+ LineRange(2, 2),
+ None,
+ )
+ let modified = @diff.DetailedLineRangeMapping(
+ LineRange(2, 3),
+ LineRange(2, 3),
+ None,
+ )
debug_inspect(
(
@common.get_change_type(added),
diff --git a/editor/internal/viewer/contrib/quick_diff/common/moon.pkg b/editor/internal/viewer/contrib/quick_diff/common/moon.pkg
index d75f76d8d..36e918efd 100644
--- a/editor/internal/viewer/contrib/quick_diff/common/moon.pkg
+++ b/editor/internal/viewer/contrib/quick_diff/common/moon.pkg
@@ -3,3 +3,7 @@ import {
"moonbitlang/editor/viewer/common/diff",
"moonbitlang/editor/viewer/common/quick_diff_api",
}
+
+import {
+ "moonbitlang/editor/viewer/common/model",
+} for "wbtest"
diff --git a/editor/internal/viewer/contrib/quick_diff/common/quick_diff_wbtest.mbt b/editor/internal/viewer/contrib/quick_diff/common/quick_diff_wbtest.mbt
index 6e984de1f..526c061e6 100644
--- a/editor/internal/viewer/contrib/quick_diff/common/quick_diff_wbtest.mbt
+++ b/editor/internal/viewer/contrib/quick_diff/common/quick_diff_wbtest.mbt
@@ -3,11 +3,11 @@ fn test_change(
original : Array[String],
modified : Array[String],
) -> @diff.DetailedLineRangeMapping raise {
- let changes = @diff.get_default().compute_diff(original, modified, {
- ignore_trim_whitespace: false,
- max_computation_time_ms: 0,
- compute_moves: false,
- }).changes
+ let changes = @diff.CoreDocumentDiffProvider().compute_diff(
+ TextSnapshot(original.join("\n")),
+ TextSnapshot(modified.join("\n")),
+ { ignore_trim_whitespace: false },
+ ).changes
assert_eq(changes.length(), 1)
changes[0]
}
diff --git a/editor/internal/viewer/contrib/references/browser/moon.pkg b/editor/internal/viewer/contrib/references/browser/moon.pkg
index 40c826b45..717893105 100644
--- a/editor/internal/viewer/contrib/references/browser/moon.pkg
+++ b/editor/internal/viewer/contrib/references/browser/moon.pkg
@@ -1,12 +1,17 @@
import {
"moonbitlang/editor/base/browser" @base_browser,
"moonbitlang/editor/base/common" @base_common,
+ "moonbitlang/editor/language",
+ "moonbitlang/editor/internal/viewer/contrib/goto_symbol",
"moonbitlang/editor/internal/viewer/contrib/references",
"moonbitlang/editor/internal/viewer/ui/scrollbar",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
"moonbit-community/rabbita/dom" @rdom,
}
import {
+ "moonbitlang/async",
"moonbitlang/editor/language",
} for "wbtest"
diff --git a/editor/internal/viewer/contrib/references/browser/pkg.generated.mbti b/editor/internal/viewer/contrib/references/browser/pkg.generated.mbti
index 013fbd660..80c674e51 100644
--- a/editor/internal/viewer/contrib/references/browser/pkg.generated.mbti
+++ b/editor/internal/viewer/contrib/references/browser/pkg.generated.mbti
@@ -3,7 +3,12 @@ package "moonbitlang/editor/internal/viewer/contrib/references/browser"
import {
"moonbit-community/rabbita/dom",
+ "moonbitlang/editor/base/common",
+ "moonbitlang/editor/internal/viewer/contrib/goto_symbol",
"moonbitlang/editor/internal/viewer/contrib/references",
+ "moonbitlang/editor/language",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
}
// Values
@@ -12,6 +17,45 @@ pub let references_peek_height_in_lines : Int
// Errors
// Types and methods
+pub struct ReferencesController {
+ // private fields
+}
+pub fn ReferencesController::ReferencesController(ReferencesPeekHost, ReferencesPeekPreviewFactory, resolver? : @navigation_api.TextModelResolverHandle, (async () -> Unit noraise) -> Unit) -> Self
+pub fn ReferencesController::close(Self, restore_focus~ : Bool) -> Unit
+pub fn ReferencesController::contains_focus(Self) -> Bool
+pub fn ReferencesController::dispose(Self) -> Unit
+pub fn ReferencesController::phase(Self) -> @references.ReferencesPeekPhase
+pub fn ReferencesController::set_task_launcher(Self, (async () -> Unit noraise) -> Unit) -> Unit
+pub fn ReferencesController::toggle_locations(Self, @goto_symbol.SymbolNavigationAnchor, @references.ReferencesPeekMode, Array[@language.Location]) -> Bool
+
+pub(all) struct ReferencesPeekHost {
+ is_anchor_current : (@goto_symbol.SymbolNavigationAnchor) -> Bool
+ mount : (@dom.Element, @goto_symbol.SymbolNavigationAnchor) -> ReferencesPeekMount?
+ reveal_anchor : (@goto_symbol.SymbolNavigationAnchor) -> Unit
+ restore_source_focus : () -> Unit
+ open_location : async (@language.Location, @navigation_api.OpenLocationMode, String) -> Bool noraise
+ show_message : (@goto_symbol.SymbolNavigationAnchor, String) -> Unit
+}
+
+pub struct ReferencesPeekMount {
+ // private fields
+}
+pub fn ReferencesPeekMount::ReferencesPeekMount(is_current~ : () -> Bool, dispose~ : () -> Unit) -> Self
+pub fn ReferencesPeekMount::dispose(Self) -> Unit
+pub fn ReferencesPeekMount::is_current(Self) -> Bool
+
+pub struct ReferencesPeekPreview {
+ // private fields
+}
+pub fn ReferencesPeekPreview::ReferencesPeekPreview(contains_focus~ : () -> Bool, focus~ : () -> Unit, dispose~ : () -> Unit) -> Self
+pub fn ReferencesPeekPreview::contains_focus(Self) -> Bool
+pub fn ReferencesPeekPreview::dispose(Self) -> Unit
+pub fn ReferencesPeekPreview::focus(Self) -> Unit
+
+pub(all) struct ReferencesPeekPreviewFactory {
+ create : (@dom.Element, @model.TextModel, @common.Range, Array[@common.Range], @references.ReferencesPeekMode) -> ReferencesPeekPreview?
+}
+
pub struct ReferencesPeekWidget {
// private fields
}
diff --git a/editor/internal/viewer/contrib/references/browser/references_controller.mbt b/editor/internal/viewer/contrib/references/browser/references_controller.mbt
new file mode 100644
index 000000000..b319c7c60
--- /dev/null
+++ b/editor/internal/viewer/contrib/references/browser/references_controller.mbt
@@ -0,0 +1,1079 @@
+///|
+/// Exact surface mount owned by one Peek session. The adapter captures its
+/// concrete ViewZone/overlay or Markdown overlay node; the shared controller
+/// only asks whether that mount is still current and disposes it once.
+pub struct ReferencesPeekMount {
+ priv is_current_callback : () -> Bool
+ priv dispose_callback : () -> Unit
+ priv mut disposed : Bool
+}
+
+///|
+pub fn ReferencesPeekMount::ReferencesPeekMount(
+ is_current~ : () -> Bool,
+ dispose~ : () -> Unit,
+) -> ReferencesPeekMount {
+ {
+ is_current_callback: is_current,
+ dispose_callback: dispose,
+ disposed: false,
+ }
+}
+
+///|
+pub fn ReferencesPeekMount::is_current(self : ReferencesPeekMount) -> Bool {
+ !self.disposed && (self.is_current_callback)()
+}
+
+///|
+pub fn ReferencesPeekMount::dispose(self : ReferencesPeekMount) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ (self.dispose_callback)()
+}
+
+///|
+/// Surface-independent lifetime of the embedded CodeEditor preview. The
+/// injected factory owns concrete editor construction and decorations.
+pub struct ReferencesPeekPreview {
+ priv contains_focus_callback : () -> Bool
+ priv focus_callback : () -> Unit
+ priv dispose_callback : () -> Unit
+ priv mut disposed : Bool
+}
+
+///|
+pub fn ReferencesPeekPreview::ReferencesPeekPreview(
+ contains_focus~ : () -> Bool,
+ focus~ : () -> Unit,
+ dispose~ : () -> Unit,
+) -> ReferencesPeekPreview {
+ {
+ contains_focus_callback: contains_focus,
+ focus_callback: focus,
+ dispose_callback: dispose,
+ disposed: false,
+ }
+}
+
+///|
+pub fn ReferencesPeekPreview::contains_focus(
+ self : ReferencesPeekPreview,
+) -> Bool {
+ !self.disposed && (self.contains_focus_callback)()
+}
+
+///|
+pub fn ReferencesPeekPreview::focus(self : ReferencesPeekPreview) -> Unit {
+ if !self.disposed {
+ (self.focus_callback)()
+ }
+}
+
+///|
+pub fn ReferencesPeekPreview::dispose(self : ReferencesPeekPreview) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ (self.dispose_callback)()
+}
+
+///|
+pub(all) struct ReferencesPeekPreviewFactory {
+ create : (
+ @rdom.Element,
+ @model.TextModel,
+ @base_common.Range,
+ Array[@base_common.Range],
+ @references.ReferencesPeekMode,
+ ) -> ReferencesPeekPreview?
+}
+
+///|
+/// The only surface capabilities visible to the shared controller.
+pub(all) struct ReferencesPeekHost {
+ is_anchor_current : (@goto_symbol.SymbolNavigationAnchor) -> Bool
+ mount : (@rdom.Element, @goto_symbol.SymbolNavigationAnchor) -> ReferencesPeekMount?
+ reveal_anchor : (@goto_symbol.SymbolNavigationAnchor) -> Unit
+ restore_source_focus : () -> Unit
+ open_location : async (
+ @language.Location,
+ @navigation_api.OpenLocationMode,
+ String,
+ ) -> Bool noraise
+ show_message : (@goto_symbol.SymbolNavigationAnchor, String) -> Unit
+}
+
+///|
+priv struct ReferencesGroupPreviewOwner {
+ group_index : Int
+ mut generation : Int
+ mut source : @language.CancellationTokenSource?
+ mut reference : @navigation_api.TextModelReference?
+}
+
+///|
+fn ReferencesGroupPreviewOwner::ReferencesGroupPreviewOwner(
+ group_index : Int,
+) -> ReferencesGroupPreviewOwner {
+ { group_index, generation: 0, source: None, reference: None }
+}
+
+///|
+priv struct DetachedReferencesGroupResources {
+ sources : Array[@language.CancellationTokenSource]
+ references : Array[@navigation_api.TextModelReference]
+}
+
+///|
+/// One mutable Peek state machine per source surface. Controller instances are
+/// never shared between Code and Markdown surfaces.
+pub struct ReferencesController {
+ priv host : ReferencesPeekHost
+ priv preview_factory : ReferencesPeekPreviewFactory
+ priv resolver : @navigation_api.TextModelResolverHandle?
+ priv mut launch_async_task : (async () -> Unit noraise) -> Unit
+ priv mut generation : Int
+ priv mut preview_generation : Int
+ priv mut mode : @references.ReferencesPeekMode?
+ priv mut phase : @references.ReferencesPeekPhase
+ priv mut anchor : @goto_symbol.SymbolNavigationAnchor?
+ priv mut session_key : @references.ReferencesSessionKey?
+ priv mut model : @references.ReferencesModel?
+ priv mut selected_index : Int
+ priv mut widget : ReferencesPeekWidget?
+ priv mut mount : ReferencesPeekMount?
+ priv mut preview_source : @language.CancellationTokenSource?
+ priv mut preview_reference : @navigation_api.TextModelReference?
+ priv mut preview : ReferencesPeekPreview?
+ priv mut group_preview_owners : Array[ReferencesGroupPreviewOwner]
+ priv mut focus_frame : @base_common.Disposable?
+ priv mut disposed : Bool
+}
+
+///|
+pub fn ReferencesController::ReferencesController(
+ host : ReferencesPeekHost,
+ preview_factory : ReferencesPeekPreviewFactory,
+ resolver? : @navigation_api.TextModelResolverHandle,
+ launch_async_task : (async () -> Unit noraise) -> Unit,
+) -> ReferencesController {
+ {
+ host,
+ preview_factory,
+ resolver,
+ launch_async_task,
+ generation: 0,
+ preview_generation: 0,
+ mode: None,
+ phase: PeekClosed,
+ anchor: None,
+ session_key: None,
+ model: None,
+ selected_index: -1,
+ widget: None,
+ mount: None,
+ preview_source: None,
+ preview_reference: None,
+ preview: None,
+ group_preview_owners: [],
+ focus_frame: None,
+ disposed: false,
+ }
+}
+
+///|
+pub fn ReferencesController::set_task_launcher(
+ self : ReferencesController,
+ launch : (async () -> Unit noraise) -> Unit,
+) -> Unit {
+ self.launch_async_task = launch
+}
+
+///|
+fn ReferencesController::anchor_is_current(
+ self : ReferencesController,
+ anchor : @goto_symbol.SymbolNavigationAnchor,
+) -> Bool {
+ !self.disposed &&
+ anchor.model_is_current() &&
+ (self.host.is_anchor_current)(anchor)
+}
+
+///|
+fn ReferencesController::session_is_current(
+ self : ReferencesController,
+ generation : Int,
+) -> Bool {
+ guard !self.disposed && self.generation == generation else { return false }
+ guard self.anchor is Some(anchor) && self.anchor_is_current(anchor) else {
+ return false
+ }
+ guard self.widget is Some(_) && self.mount is Some(mount) else {
+ return false
+ }
+ mount.is_current()
+}
+
+///|
+fn ReferencesController::session_is_open(
+ self : ReferencesController,
+ key : @references.ReferencesSessionKey,
+ mode : @references.ReferencesPeekMode,
+) -> Bool {
+ self.session_key == Some(key) &&
+ self.mode == Some(mode) &&
+ self.widget is Some(_) &&
+ self.mount is Some(_) &&
+ self.session_is_current(self.generation)
+}
+
+///|
+/// Toggles one already-computed result set. Provider work is owned by the
+/// shared SymbolNavigationAction, so this method never re-queries it.
+pub fn ReferencesController::toggle_locations(
+ self : ReferencesController,
+ anchor : @goto_symbol.SymbolNavigationAnchor,
+ mode : @references.ReferencesPeekMode,
+ locations : Array[@language.Location],
+) -> Bool {
+ guard self.anchor_is_current(anchor) else { return false }
+ let key = @references.ReferencesSessionKey::from_model(
+ anchor.model(),
+ anchor.surface_generation(),
+ anchor.position(),
+ )
+ if self.session_is_open(key, mode) {
+ self.close(restore_focus=true)
+ return true
+ }
+ self.close(restore_focus=false)
+ guard self.anchor_is_current(anchor) else { return false }
+ self.generation += 1
+ let generation = self.generation
+ self.mode = Some(mode)
+ self.phase = match mode {
+ Definitions => PeekLoadingDefinitions(generation)
+ References => PeekLoadingReferences(generation)
+ }
+ self.anchor = Some(anchor)
+ self.session_key = Some(key)
+ let widget = ReferencesPeekWidget(mode, {
+ on_close: () => {
+ if self.session_is_current(generation) {
+ self.close(restore_focus=true)
+ }
+ },
+ on_select: item => {
+ if self.session_is_current(generation) {
+ self.select_item(item)
+ }
+ },
+ on_confirm: item => {
+ if self.session_is_current(generation) {
+ self.confirm_item(item, Current)
+ }
+ },
+ on_open_side: item => {
+ if self.session_is_current(generation) {
+ self.confirm_item(item, Side)
+ }
+ },
+ on_next: () => {
+ if self.session_is_current(generation) {
+ self.move_selection(1)
+ }
+ },
+ on_previous: () => {
+ if self.session_is_current(generation) {
+ self.move_selection(-1)
+ }
+ },
+ on_expand_group: group => {
+ if self.session_is_current(generation) {
+ self.load_group(group)
+ }
+ },
+ })
+ self.widget = Some(widget)
+ let mount = (self.host.mount)(widget.get_dom_node(), anchor)
+ guard mount is Some(mount) else {
+ if self.generation == generation &&
+ self.widget is Some(current_widget) &&
+ physical_equal(current_widget, widget) {
+ self.widget = None
+ self.mode = None
+ self.anchor = None
+ self.session_key = None
+ self.phase = PeekClosed
+ }
+ widget.dispose()
+ return false
+ }
+ let widget_is_current = match self.widget {
+ Some(current_widget) => physical_equal(current_widget, widget)
+ None => false
+ }
+ if self.generation != generation ||
+ !widget_is_current ||
+ !self.anchor_is_current(anchor) ||
+ !mount.is_current() {
+ mount.dispose()
+ let still_owns_widget = self.generation == generation &&
+ self.widget is Some(current_widget) &&
+ physical_equal(current_widget, widget)
+ if still_owns_widget {
+ self.close(restore_focus=false)
+ } else {
+ widget.dispose()
+ }
+ return false
+ }
+ self.mount = Some(mount)
+ (self.host.reveal_anchor)(anchor)
+ guard self.session_is_current(generation) else {
+ self.close(restore_focus=false)
+ return false
+ }
+ self.install_locations(generation, locations.copy())
+ guard self.session_is_current(generation) else { return true }
+ self.focus_frame = Some(
+ @base_browser.schedule_at_next_animation_frame(
+ () => {
+ if self.generation == generation {
+ self.focus_frame = None
+ }
+ if self.session_is_current(generation) &&
+ self.widget is Some(current_widget) &&
+ physical_equal(current_widget, widget) {
+ widget.focus()
+ }
+ },
+ priority=0,
+ ),
+ )
+ true
+}
+
+///|
+fn ReferencesController::install_locations(
+ self : ReferencesController,
+ generation : Int,
+ locations : Array[@language.Location],
+) -> Unit {
+ guard self.session_is_current(generation) else { return }
+ guard self.anchor is Some(anchor) && self.widget is Some(widget) else {
+ return
+ }
+ let result_model = @references.ReferencesModel(locations)
+ self.model = Some(result_model)
+ let owners : Array[ReferencesGroupPreviewOwner] = []
+ for group in result_model.groups {
+ owners.push(ReferencesGroupPreviewOwner(group.group_index))
+ }
+ self.group_preview_owners = owners
+ if result_model.is_empty() {
+ if self.mode == Some(Definitions) {
+ self.close(restore_focus=true)
+ (self.host.show_message)(anchor, definition_no_result_message(anchor))
+ } else {
+ widget.show_empty()
+ self.phase = PeekShown(generation, self.preview_generation)
+ }
+ return
+ }
+ guard result_model.nearest_reference(anchor.model().uri, anchor.position())
+ is Some(selected) else {
+ return
+ }
+ self.selected_index = selected.flat_index
+ widget.set_results(result_model, Some(selected))
+ widget.show_preview_loading()
+ self.load_preview(generation, selected.flat_index)
+}
+
+///|
+fn definition_no_result_message(
+ anchor : @goto_symbol.SymbolNavigationAnchor,
+) -> String {
+ match anchor.model().get_word_at_position(anchor.position()) {
+ Some(word) if !word.word.is_empty() =>
+ "No definition found for '\{word.word}'"
+ _ => "No definition found"
+ }
+}
+
+///|
+fn ReferencesController::group_is_current(
+ self : ReferencesController,
+ generation : Int,
+ group : @references.ReferenceGroup,
+) -> Bool {
+ guard self.session_is_current(generation) && self.model is Some(model) else {
+ return false
+ }
+ group.group_index >= 0 &&
+ group.group_index < model.groups.length() &&
+ model.groups[group.group_index] == group
+}
+
+///|
+fn ReferencesController::group_slot_is_current(
+ self : ReferencesController,
+ generation : Int,
+ group : @references.ReferenceGroup,
+ owner_generation : Int,
+) -> Bool {
+ guard self.group_is_current(generation, group) else { return false }
+ guard group.group_index >= 0 &&
+ group.group_index < self.group_preview_owners.length() else {
+ return false
+ }
+ let owner = self.group_preview_owners[group.group_index]
+ owner.group_index == group.group_index && owner.generation == owner_generation
+}
+
+///|
+fn reference_group_row_previews(
+ group : @references.ReferenceGroup,
+ model : @model.TextModel,
+) -> Array[@references.ReferenceRowPreview] {
+ group.references.map(item => item.row_preview(Some(model)))
+}
+
+///|
+fn ReferencesController::load_group(
+ self : ReferencesController,
+ group : @references.ReferenceGroup,
+) -> Unit {
+ let generation = self.generation
+ guard self.group_is_current(generation, group) else { return }
+ guard group.group_index >= 0 &&
+ group.group_index < self.group_preview_owners.length() else {
+ return
+ }
+ let owner = self.group_preview_owners[group.group_index]
+ guard owner.group_index == group.group_index &&
+ owner.source is None &&
+ owner.reference is None else {
+ return
+ }
+ match self.anchor {
+ Some(anchor) if anchor.model().uri.to_string() == group.uri_string => {
+ guard self.widget is Some(widget) else { return }
+ widget.set_group_previews(
+ group.group_index,
+ reference_group_row_previews(group, anchor.model()),
+ )
+ return
+ }
+ _ => ()
+ }
+ guard self.resolver is Some(resolver) else { return }
+ owner.generation += 1
+ let owner_generation = owner.generation
+ let source = @language.CancellationTokenSource()
+ owner.source = Some(source)
+ let task = self.launch_async_task
+ task(async fn() noraise {
+ let reference = resolver.resolve(group.uri, source.token())
+ let current = self.group_slot_is_current(
+ generation, group, owner_generation,
+ ) &&
+ !source.token().is_cancellation_requested() &&
+ owner.source is Some(current_source) &&
+ physical_equal(current_source, source)
+ if !current {
+ match owner.source {
+ Some(current_source) if physical_equal(current_source, source) =>
+ owner.source = None
+ _ => ()
+ }
+ source.dispose(cancel=true)
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ owner.source = None
+ source.dispose()
+ guard self.group_slot_is_current(generation, group, owner_generation) else {
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ match reference {
+ None => ()
+ Some(reference) => {
+ let model = reference.model()
+ if model.is_disposed() || model.uri.to_string() != group.uri_string {
+ reference.dispose()
+ return
+ }
+ guard self.group_slot_is_current(generation, group, owner_generation) &&
+ self.widget is Some(widget) else {
+ reference.dispose()
+ return
+ }
+ owner.reference = Some(reference)
+ widget.set_group_previews(
+ group.group_index,
+ reference_group_row_previews(group, model),
+ )
+ }
+ }
+ })
+}
+
+///|
+fn ReferencesController::cancel_preview_request(
+ self : ReferencesController,
+) -> Unit {
+ let source = self.preview_source
+ self.preview_source = None
+ match source {
+ Some(source) => source.dispose(cancel=true)
+ None => ()
+ }
+}
+
+///|
+fn ReferencesController::clear_installed_preview(
+ self : ReferencesController,
+) -> Bool {
+ let preview = self.preview
+ self.preview = None
+ let reference = self.preview_reference
+ self.preview_reference = None
+ let had_focus = match preview {
+ Some(preview) => preview.contains_focus()
+ None => false
+ }
+ match preview {
+ Some(preview) => preview.dispose()
+ None => ()
+ }
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ had_focus
+}
+
+///|
+fn ReferencesController::preview_selection_is_current(
+ self : ReferencesController,
+ generation : Int,
+ result_model : @references.ReferencesModel,
+ index : Int,
+ item : @references.ReferenceItem,
+ widget : ReferencesPeekWidget,
+) -> Bool {
+ self.session_is_current(generation) &&
+ self.model is Some(current_model) &&
+ physical_equal(current_model, result_model) &&
+ index >= 0 &&
+ index < current_model.references.length() &&
+ current_model.references[index] == item &&
+ self.widget is Some(current_widget) &&
+ physical_equal(current_widget, widget)
+}
+
+///|
+fn ReferencesController::preview_commit_is_current(
+ self : ReferencesController,
+ generation : Int,
+ preview_generation : Int,
+ expected_uri : String,
+ model : @model.TextModel,
+ result_model : @references.ReferencesModel,
+ widget : ReferencesPeekWidget,
+) -> Bool {
+ self.session_is_current(generation) &&
+ self.preview_generation == preview_generation &&
+ !model.is_disposed() &&
+ model.uri.to_string() == expected_uri &&
+ self.model is Some(current_model) &&
+ physical_equal(current_model, result_model) &&
+ self.widget is Some(current_widget) &&
+ physical_equal(current_widget, widget)
+}
+
+///|
+fn ReferencesController::load_preview(
+ self : ReferencesController,
+ generation : Int,
+ index : Int,
+) -> Unit {
+ guard self.session_is_current(generation) &&
+ self.model is Some(result_model) &&
+ self.widget is Some(widget) &&
+ index >= 0 &&
+ index < result_model.references.length() else {
+ return
+ }
+ if index == self.selected_index &&
+ (self.preview is Some(_) || self.preview_source is Some(_)) {
+ return
+ }
+ let item = result_model.references[index]
+ self.cancel_preview_request()
+ guard self.preview_selection_is_current(
+ generation, result_model, index, item, widget,
+ ) else {
+ return
+ }
+ self.selected_index = index
+ widget.set_selected(item)
+ guard self.preview_selection_is_current(
+ generation, result_model, index, item, widget,
+ ) else {
+ return
+ }
+ widget.show_preview_loading()
+ guard self.preview_selection_is_current(
+ generation, result_model, index, item, widget,
+ ) else {
+ return
+ }
+ self.preview_generation += 1
+ let preview_generation = self.preview_generation
+ self.phase = PeekLoadingPreview(generation, preview_generation)
+ let location = item.location()
+ guard self.anchor is Some(anchor) else { return }
+ if anchor.model().uri.to_string() == item.uri_string &&
+ !anchor.model().is_disposed() {
+ self.schedule_preview(
+ generation,
+ preview_generation,
+ location,
+ anchor.model(),
+ None,
+ )
+ return
+ }
+ guard self.resolver is Some(resolver) else {
+ self.show_preview_unavailable(
+ generation, preview_generation, "No preview available",
+ )
+ return
+ }
+ let source = @language.CancellationTokenSource()
+ self.preview_source = Some(source)
+ let task = self.launch_async_task
+ task(async fn() noraise {
+ let reference = resolver.resolve(location.uri, source.token())
+ let current = self.session_is_current(generation) &&
+ self.preview_generation == preview_generation &&
+ self.preview_source is Some(current_source) &&
+ physical_equal(current_source, source) &&
+ !source.token().is_cancellation_requested()
+ if !current {
+ match self.preview_source {
+ Some(current_source) if physical_equal(current_source, source) =>
+ self.preview_source = None
+ _ => ()
+ }
+ source.dispose(cancel=true)
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ self.preview_source = None
+ source.dispose()
+ match reference {
+ None =>
+ self.show_preview_unavailable(
+ generation, preview_generation, "No preview available",
+ )
+ Some(reference) => {
+ let model = reference.model()
+ if model.is_disposed() ||
+ model.uri.to_string() != location.uri.to_string() {
+ reference.dispose()
+ self.show_preview_unavailable(
+ generation, preview_generation, "No preview available",
+ )
+ return
+ }
+ self.schedule_preview(
+ generation,
+ preview_generation,
+ location,
+ model,
+ Some(reference),
+ )
+ }
+ }
+ })
+}
+
+///|
+#warnings("-unused_async")
+fn ReferencesController::schedule_preview(
+ self : ReferencesController,
+ generation : Int,
+ preview_generation : Int,
+ location : @language.Location,
+ model : @model.TextModel,
+ reference : @navigation_api.TextModelReference?,
+) -> Unit {
+ guard !model.is_disposed() &&
+ self.session_is_current(generation) &&
+ self.preview_generation == preview_generation else {
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ let task = self.launch_async_task
+ task(async fn() noraise {
+ guard self.model is Some(result_model) &&
+ self.widget is Some(widget) &&
+ self.mode is Some(mode) &&
+ self.preview_commit_is_current(
+ generation,
+ preview_generation,
+ location.uri.to_string(),
+ model,
+ result_model,
+ widget,
+ ) else {
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ let restore_preview_focus = self.clear_installed_preview()
+ guard self.preview_commit_is_current(
+ generation,
+ preview_generation,
+ location.uri.to_string(),
+ model,
+ result_model,
+ widget,
+ ) else {
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ let matches : Array[@base_common.Range] = []
+ let uri = model.uri.to_string()
+ for item in result_model.references {
+ if item.uri_string == uri {
+ matches.push(model.validate_range(item.range))
+ }
+ }
+ let preview = (self.preview_factory.create)(
+ widget.get_preview_host(),
+ model,
+ model.validate_range(location.range),
+ matches,
+ mode,
+ )
+ guard preview is Some(preview) else {
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ self.show_preview_unavailable(
+ generation, preview_generation, "No preview available",
+ )
+ return
+ }
+ if !self.preview_commit_is_current(
+ generation,
+ preview_generation,
+ location.uri.to_string(),
+ model,
+ result_model,
+ widget,
+ ) {
+ preview.dispose()
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ widget.show_preview_ready()
+ if !self.preview_commit_is_current(
+ generation,
+ preview_generation,
+ location.uri.to_string(),
+ model,
+ result_model,
+ widget,
+ ) {
+ preview.dispose()
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ if restore_preview_focus {
+ preview.focus()
+ }
+ if !self.preview_commit_is_current(
+ generation,
+ preview_generation,
+ location.uri.to_string(),
+ model,
+ result_model,
+ widget,
+ ) {
+ preview.dispose()
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ return
+ }
+ self.preview = Some(preview)
+ self.preview_reference = reference
+ self.phase = PeekShown(generation, preview_generation)
+ })
+}
+
+///|
+fn ReferencesController::show_preview_unavailable(
+ self : ReferencesController,
+ generation : Int,
+ preview_generation : Int,
+ message : String,
+) -> Unit {
+ guard self.session_is_current(generation) &&
+ self.preview_generation == preview_generation else {
+ return
+ }
+ guard self.widget is Some(widget) else { return }
+ let had_focus = self.clear_installed_preview()
+ guard self.session_is_current(generation) &&
+ self.preview_generation == preview_generation &&
+ self.widget is Some(current_widget) &&
+ physical_equal(current_widget, widget) else {
+ return
+ }
+ self.phase = PeekUnavailable(generation, preview_generation)
+ widget.show_preview_unavailable(message)
+ if had_focus {
+ widget.focus()
+ }
+}
+
+///|
+fn ReferencesController::item_is_current(
+ self : ReferencesController,
+ item : @references.ReferenceItem,
+) -> Bool {
+ guard self.model is Some(model) else { return false }
+ item.flat_index >= 0 &&
+ item.flat_index < model.references.length() &&
+ model.references[item.flat_index] == item
+}
+
+///|
+fn ReferencesController::select_item(
+ self : ReferencesController,
+ item : @references.ReferenceItem,
+) -> Unit {
+ if self.item_is_current(item) {
+ self.load_preview(self.generation, item.flat_index)
+ }
+}
+
+///|
+fn ReferencesController::move_selection(
+ self : ReferencesController,
+ delta : Int,
+) -> Unit {
+ guard self.model is Some(model) && !model.references.is_empty() else {
+ return
+ }
+ let current = if self.selected_index >= 0 &&
+ self.selected_index < model.references.length() {
+ model.references[self.selected_index]
+ } else {
+ model.references[0]
+ }
+ let next = if delta < 0 {
+ model.previous_reference(current)
+ } else {
+ model.next_reference(current)
+ }
+ match next {
+ Some(item) => self.select_item(item)
+ None => ()
+ }
+}
+
+///|
+fn ReferencesController::confirm_item(
+ self : ReferencesController,
+ item : @references.ReferenceItem,
+ open_mode : @navigation_api.OpenLocationMode,
+) -> Unit {
+ guard self.item_is_current(item) &&
+ self.mode is Some(peek_mode) &&
+ self.anchor is Some(anchor) else {
+ return
+ }
+ let location = item.location()
+ let task = self.launch_async_task
+ let closed_generation = self.generation + 1
+ self.close(restore_focus=true)
+ task(async fn() noraise {
+ guard !self.disposed &&
+ self.generation == closed_generation &&
+ self.anchor_is_current(anchor) else {
+ return
+ }
+ let accepted = (self.host.open_location)(
+ location,
+ open_mode,
+ if peek_mode == Definitions {
+ "peekDefinition"
+ } else {
+ "showReferences"
+ },
+ )
+ if !accepted &&
+ self.generation == closed_generation &&
+ self.anchor_is_current(anchor) &&
+ peek_mode == Definitions {
+ (self.host.show_message)(anchor, "Unable to open definition")
+ }
+ })
+}
+
+///|
+fn ReferencesController::detach_group_resources(
+ self : ReferencesController,
+) -> DetachedReferencesGroupResources {
+ let owners = self.group_preview_owners
+ self.group_preview_owners = []
+ let sources : Array[@language.CancellationTokenSource] = []
+ let references : Array[@navigation_api.TextModelReference] = []
+ for owner in owners {
+ owner.generation += 1
+ match owner.source {
+ Some(source) => sources.push(source)
+ None => ()
+ }
+ match owner.reference {
+ Some(reference) => references.push(reference)
+ None => ()
+ }
+ owner.source = None
+ owner.reference = None
+ }
+ { sources, references }
+}
+
+///|
+pub fn ReferencesController::close(
+ self : ReferencesController,
+ restore_focus~ : Bool,
+) -> Unit {
+ self.generation += 1
+ let closing_generation = self.generation
+ self.preview_generation += 1
+ self.phase = PeekClosed
+ let anchor = self.anchor
+ self.anchor = None
+ self.session_key = None
+ self.mode = None
+ self.model = None
+ self.selected_index = -1
+ let source = self.preview_source
+ self.preview_source = None
+ let preview = self.preview
+ self.preview = None
+ let reference = self.preview_reference
+ self.preview_reference = None
+ let groups = self.detach_group_resources()
+ let frame = self.focus_frame
+ self.focus_frame = None
+ let mount = self.mount
+ self.mount = None
+ let widget = self.widget
+ self.widget = None
+ match source {
+ Some(source) => source.dispose(cancel=true)
+ None => ()
+ }
+ for source in groups.sources {
+ source.dispose(cancel=true)
+ }
+ match frame {
+ Some(frame) => frame.dispose()
+ None => ()
+ }
+ match preview {
+ Some(preview) => preview.dispose()
+ None => ()
+ }
+ match reference {
+ Some(reference) => reference.dispose()
+ None => ()
+ }
+ for reference in groups.references {
+ reference.dispose()
+ }
+ match mount {
+ Some(mount) => mount.dispose()
+ None => ()
+ }
+ match widget {
+ Some(widget) => widget.dispose()
+ None => ()
+ }
+ if restore_focus &&
+ !self.disposed &&
+ self.generation == closing_generation &&
+ self.phase == PeekClosed &&
+ anchor is Some(anchor) &&
+ self.anchor_is_current(anchor) {
+ (self.host.restore_source_focus)()
+ }
+}
+
+///|
+pub fn ReferencesController::contains_focus(
+ self : ReferencesController,
+) -> Bool {
+ match self.widget {
+ Some(widget) =>
+ @base_browser.element_contains_active_element(widget.get_dom_node())
+ None => false
+ }
+}
+
+///|
+pub fn ReferencesController::phase(
+ self : ReferencesController,
+) -> @references.ReferencesPeekPhase {
+ self.phase
+}
+
+///|
+pub fn ReferencesController::dispose(self : ReferencesController) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.close(restore_focus=false)
+}
diff --git a/editor/internal/viewer/contrib/references/browser/references_controller_wbtest.mbt b/editor/internal/viewer/contrib/references/browser/references_controller_wbtest.mbt
new file mode 100644
index 000000000..94792ef30
--- /dev/null
+++ b/editor/internal/viewer/contrib/references/browser/references_controller_wbtest.mbt
@@ -0,0 +1,512 @@
+///|
+priv struct ReferencesControllerTestTasks {
+ tasks : Array[async () -> Unit noraise]
+}
+
+///|
+fn ReferencesControllerTestTasks::ReferencesControllerTestTasks() -> ReferencesControllerTestTasks {
+ { tasks: [] }
+}
+
+///|
+fn ReferencesControllerTestTasks::schedule(
+ self : ReferencesControllerTestTasks,
+ task : async () -> Unit noraise,
+) -> Unit {
+ self.tasks.push(task)
+}
+
+///|
+async fn ReferencesControllerTestTasks::run_next(
+ self : ReferencesControllerTestTasks,
+) -> Unit noraise {
+ guard !self.tasks.is_empty() else { return }
+ self.tasks.remove(0)()
+}
+
+///|
+async fn ReferencesControllerTestTasks::run_all(
+ self : ReferencesControllerTestTasks,
+) -> Unit noraise {
+ while !self.tasks.is_empty() {
+ self.run_next()
+ }
+}
+
+///|
+priv struct ReferencesControllerTestHost {
+ source_model : @model.TextModel
+ surface_generation : Ref[Int]
+ current : Ref[Bool]
+ container : @rdom.Element
+ mount_count : Ref[Int]
+ mount_dispose_count : Ref[Int]
+ reveal_count : Ref[Int]
+ restore_focus_count : Ref[Int]
+ opened_resources : Array[String]
+ messages : Array[String]
+}
+
+///|
+fn ReferencesControllerTestHost::host(
+ self : ReferencesControllerTestHost,
+) -> ReferencesPeekHost {
+ {
+ is_anchor_current: anchor => {
+ self.current.val &&
+ anchor.model().same(self.source_model) &&
+ anchor.surface_generation() == self.surface_generation.val
+ },
+ mount: (root, _anchor) => {
+ self.mount_count.val += 1
+ self.container.append_child(root.as_node())
+ let current = Ref(true)
+ Some(
+ ReferencesPeekMount(
+ is_current=() => {
+ current.val &&
+ self.current.val &&
+ @base_browser.element_contains(self.container, root)
+ },
+ dispose=() => {
+ if current.val {
+ current.val = false
+ self.mount_dispose_count.val += 1
+ @base_browser.remove_element(root)
+ }
+ },
+ ),
+ )
+ },
+ reveal_anchor: _anchor => self.reveal_count.val += 1,
+ restore_source_focus: () => self.restore_focus_count.val += 1,
+ open_location: (location, _mode, _source) => {
+ self.opened_resources.push(location.uri.to_string())
+ true
+ },
+ show_message: (_anchor, message) => self.messages.push(message),
+ }
+}
+
+///|
+fn references_controller_test_host(
+ model : @model.TextModel,
+ surface_generation : Ref[Int],
+) -> ReferencesControllerTestHost {
+ {
+ source_model: model,
+ surface_generation,
+ current: Ref(true),
+ container: @rdom.document().create_element("div"),
+ mount_count: Ref(0),
+ mount_dispose_count: Ref(0),
+ reveal_count: Ref(0),
+ restore_focus_count: Ref(0),
+ opened_resources: [],
+ messages: [],
+ }
+}
+
+///|
+priv struct ReferencesControllerTestPreviewLog {
+ resources : Array[String]
+ targets : Array[@base_common.Range]
+ match_counts : Array[Int]
+ modes : Array[@references.ReferencesPeekMode]
+ dispose_count : Ref[Int]
+ focus_count : Ref[Int]
+ contains_focus : Ref[Bool]
+ on_dispose : Ref[(() -> Unit)?]
+}
+
+///|
+fn ReferencesControllerTestPreviewLog::ReferencesControllerTestPreviewLog() -> ReferencesControllerTestPreviewLog {
+ {
+ resources: [],
+ targets: [],
+ match_counts: [],
+ modes: [],
+ dispose_count: Ref(0),
+ focus_count: Ref(0),
+ contains_focus: Ref(false),
+ on_dispose: Ref(None),
+ }
+}
+
+///|
+fn ReferencesControllerTestPreviewLog::factory(
+ self : ReferencesControllerTestPreviewLog,
+) -> ReferencesPeekPreviewFactory {
+ {
+ create: (_host, model, target, matches, mode) => {
+ self.resources.push(model.uri.to_string())
+ self.targets.push(target)
+ self.match_counts.push(matches.length())
+ self.modes.push(mode)
+ Some(
+ ReferencesPeekPreview(
+ contains_focus=() => self.contains_focus.val,
+ focus=() => self.focus_count.val += 1,
+ dispose=() => {
+ self.dispose_count.val += 1
+ match self.on_dispose.val {
+ Some(callback) => callback()
+ None => ()
+ }
+ },
+ ),
+ )
+ },
+ }
+}
+
+///|
+fn references_controller_test_model(resource : String) -> @model.TextModel {
+ TextModel(
+ try! @base_common.Uri::parse(resource),
+ "controller.mbt",
+ "moonbit",
+ 1,
+ "revision-1",
+ "let source = target\nlet second = target\nlet third = target\n",
+ )
+}
+
+///|
+fn references_controller_test_anchor(
+ model : @model.TextModel,
+ surface_generation : Int,
+) -> @goto_symbol.SymbolNavigationAnchor {
+ @goto_symbol.SymbolNavigationAnchor::from_model(
+ model,
+ Position(1, 14),
+ Range(1, 14, 1, 20),
+ surface_generation,
+ )
+}
+
+///|
+test "preview cancellation callbacks cannot clobber a replacement source" {
+ let browser = install_references_peek_test_browser()
+ defer browser.dispose()
+ let model = references_controller_test_model("file:///cancel-source.mbt")
+ defer model.dispose()
+ let surface_generation = Ref(1)
+ let host = references_controller_test_host(model, surface_generation)
+ let tasks = ReferencesControllerTestTasks()
+ let controller = ReferencesController(
+ host.host(),
+ ReferencesControllerTestPreviewLog().factory(),
+ resolver?=None,
+ task => tasks.schedule(task),
+ )
+ let old_source = @language.CancellationTokenSource()
+ let replacement_source = @language.CancellationTokenSource()
+ old_source
+ .token()
+ .on_cancellation_requested(() => {
+ controller.preview_source = Some(replacement_source)
+ })
+ |> ignore
+ controller.preview_source = Some(old_source)
+ controller.cancel_preview_request()
+ guard controller.preview_source is Some(current_source) else {
+ fail("preview cancellation clobbered its replacement source")
+ }
+ assert_true(physical_equal(current_source, replacement_source))
+ controller.preview_source = None
+ replacement_source.dispose(cancel=true)
+ controller.dispose()
+}
+
+///|
+async test "controller toggles one surface session and replaces previews in place" {
+ let browser = install_references_peek_test_browser()
+ defer browser.dispose()
+ let model = references_controller_test_model("file:///source.mbt")
+ defer model.dispose()
+ let surface_generation = Ref(4)
+ let host = references_controller_test_host(model, surface_generation)
+ let previews = ReferencesControllerTestPreviewLog()
+ let tasks = ReferencesControllerTestTasks()
+ let controller = ReferencesController(
+ host.host(),
+ previews.factory(),
+ resolver?=None,
+ task => tasks.schedule(task),
+ )
+ let anchor = references_controller_test_anchor(model, surface_generation.val)
+ let locations : Array[@language.Location] = [
+ { uri: model.uri, range: Range(1, 14, 1, 20) },
+ { uri: model.uri, range: Range(2, 14, 2, 20) },
+ ]
+
+ assert_true(controller.toggle_locations(anchor, Definitions, locations))
+ assert_eq(tasks.tasks.length(), 1)
+ tasks.run_all()
+ browser.flush_animation_frames()
+ assert_eq(host.mount_count.val, 1)
+ assert_eq(host.reveal_count.val, 1)
+ assert_eq(previews.resources, [model.uri.to_string()])
+ assert_eq(previews.targets, [Range(1, 14, 1, 20)])
+ assert_eq(previews.match_counts, [2])
+ assert_true(controller.phase() is PeekShown(_, _))
+
+ previews.contains_focus.val = true
+ controller.move_selection(1)
+ assert_eq(previews.dispose_count.val, 0)
+ assert_eq(tasks.tasks.length(), 1)
+ tasks.run_all()
+ assert_eq(previews.dispose_count.val, 1)
+ assert_eq(previews.resources.length(), 2)
+ assert_eq(previews.targets[1], Range(2, 14, 2, 20))
+ assert_eq(previews.focus_count.val, 1)
+ assert_true(controller.phase() is PeekShown(_, _))
+
+ assert_true(controller.toggle_locations(anchor, Definitions, locations))
+ assert_eq(controller.phase(), PeekClosed)
+ assert_eq(host.mount_dispose_count.val, 1)
+ assert_eq(previews.dispose_count.val, 2)
+ assert_eq(host.restore_focus_count.val, 1)
+ controller.dispose()
+}
+
+///|
+async test "separate controllers keep Code and Markdown-like sessions isolated" {
+ let browser = install_references_peek_test_browser()
+ defer browser.dispose()
+ let model = references_controller_test_model("file:///shared-source.mbt")
+ defer model.dispose()
+ let code_generation = Ref(1)
+ let markdown_generation = Ref(9)
+ let code_host = references_controller_test_host(model, code_generation)
+ let markdown_host = references_controller_test_host(
+ model, markdown_generation,
+ )
+ let code_preview = ReferencesControllerTestPreviewLog()
+ let markdown_preview = ReferencesControllerTestPreviewLog()
+ let tasks = ReferencesControllerTestTasks()
+ let code = ReferencesController(
+ code_host.host(),
+ code_preview.factory(),
+ resolver?=None,
+ task => tasks.schedule(task),
+ )
+ let markdown = ReferencesController(
+ markdown_host.host(),
+ markdown_preview.factory(),
+ resolver?=None,
+ task => tasks.schedule(task),
+ )
+ let location : @language.Location = {
+ uri: model.uri,
+ range: Range(1, 14, 1, 20),
+ }
+ assert_true(
+ code.toggle_locations(
+ references_controller_test_anchor(model, code_generation.val),
+ Definitions,
+ [location],
+ ),
+ )
+ assert_true(
+ markdown.toggle_locations(
+ references_controller_test_anchor(model, markdown_generation.val),
+ References,
+ [location],
+ ),
+ )
+ tasks.run_all()
+ browser.flush_animation_frames()
+ assert_true(code.phase() is PeekShown(_, _))
+ assert_true(markdown.phase() is PeekShown(_, _))
+ code.close(restore_focus=false)
+ assert_eq(code.phase(), PeekClosed)
+ assert_true(markdown.phase() is PeekShown(_, _))
+ assert_eq(code_preview.dispose_count.val, 1)
+ assert_eq(markdown_preview.dispose_count.val, 0)
+
+ markdown.dispose()
+ code.dispose()
+}
+
+///|
+async test "dispose marks the controller stale before resource callbacks can reopen it" {
+ let browser = install_references_peek_test_browser()
+ defer browser.dispose()
+ let model = references_controller_test_model("file:///dispose-source.mbt")
+ defer model.dispose()
+ let surface_generation = Ref(3)
+ let host = references_controller_test_host(model, surface_generation)
+ let previews = ReferencesControllerTestPreviewLog()
+ let tasks = ReferencesControllerTestTasks()
+ let controller = ReferencesController(
+ host.host(),
+ previews.factory(),
+ resolver?=None,
+ task => tasks.schedule(task),
+ )
+ let anchor = references_controller_test_anchor(model, surface_generation.val)
+ let location : @language.Location = {
+ uri: model.uri,
+ range: Range(1, 14, 1, 20),
+ }
+ assert_true(controller.toggle_locations(anchor, Definitions, [location]))
+ tasks.run_all()
+ assert_true(controller.phase() is PeekShown(_, _))
+ let reopen_result = Ref(true)
+ previews.on_dispose.val = Some(() => {
+ reopen_result.val = controller.toggle_locations(anchor, Definitions, [
+ location,
+ ])
+ })
+ controller.dispose()
+ assert_false(reopen_result.val)
+ assert_eq(controller.phase(), PeekClosed)
+ assert_eq(host.mount_count.val, 1)
+ assert_eq(host.mount_dispose_count.val, 1)
+ assert_eq(previews.dispose_count.val, 1)
+ browser.flush_animation_frames()
+}
+
+///|
+async test "resolver leases transfer on preview replacement and release on close" {
+ let browser = install_references_peek_test_browser()
+ defer browser.dispose()
+ let source = references_controller_test_model("file:///source.mbt")
+ let target_a = references_controller_test_model("file:///target-a.mbt")
+ let target_b = references_controller_test_model("file:///target-b.mbt")
+ defer source.dispose()
+ defer target_a.dispose()
+ defer target_b.dispose()
+ let release_a = Ref(0)
+ let release_b = Ref(0)
+ let resolver_calls : Array[String] = []
+ let resolver = @navigation_api.TextModelResolverHandle(resolve=(
+ resource,
+ _token,
+ ) => {
+ resolver_calls.push(resource.to_string())
+ if resource.to_string() == target_a.uri.to_string() {
+ Some(TextModelReference(target_a, release=() => release_a.val += 1))
+ } else if resource.to_string() == target_b.uri.to_string() {
+ Some(TextModelReference(target_b, release=() => release_b.val += 1))
+ } else {
+ None
+ }
+ })
+ let surface_generation = Ref(2)
+ let host = references_controller_test_host(source, surface_generation)
+ let previews = ReferencesControllerTestPreviewLog()
+ let tasks = ReferencesControllerTestTasks()
+ let controller = ReferencesController(
+ host.host(),
+ previews.factory(),
+ resolver~,
+ task => tasks.schedule(task),
+ )
+ let locations : Array[@language.Location] = [
+ { uri: target_a.uri, range: Range(1, 5, 1, 11) },
+ { uri: target_b.uri, range: Range(2, 5, 2, 11) },
+ ]
+ assert_true(
+ controller.toggle_locations(
+ references_controller_test_anchor(source, surface_generation.val),
+ References,
+ locations,
+ ),
+ )
+ // One lease fills the selected group snippets and another owns the editor
+ // preview, matching the controller's independent group/preview lifetimes.
+ assert_eq(tasks.tasks.length(), 2)
+ tasks.run_all()
+ assert_eq(resolver_calls.length(), 2)
+ assert_eq(resolver_calls[0], target_a.uri.to_string())
+ assert_eq(resolver_calls[1], target_a.uri.to_string())
+ assert_eq(previews.resources, [target_a.uri.to_string()])
+ assert_eq(release_a.val, 0)
+
+ controller.move_selection(1)
+ assert_eq(release_a.val, 0)
+ assert_eq(previews.dispose_count.val, 0)
+ assert_eq(tasks.tasks.length(), 2)
+ tasks.run_all()
+ assert_eq(release_a.val, 1)
+ assert_eq(previews.dispose_count.val, 1)
+ assert_eq(resolver_calls.length(), 4)
+ assert_eq(resolver_calls[2], target_b.uri.to_string())
+ assert_eq(resolver_calls[3], target_b.uri.to_string())
+ assert_eq(previews.resources[1], target_b.uri.to_string())
+ assert_eq(release_b.val, 0)
+
+ controller.close(restore_focus=false)
+ assert_eq(release_a.val, 2)
+ assert_eq(release_b.val, 2)
+ assert_eq(previews.dispose_count.val, 2)
+ assert_false(target_a.is_disposed())
+ assert_false(target_b.is_disposed())
+ browser.flush_animation_frames()
+ controller.dispose()
+}
+
+///|
+async test "stale and invalid resolver results are released without preview installation" {
+ let browser = install_references_peek_test_browser()
+ defer browser.dispose()
+ let source = references_controller_test_model("file:///source.mbt")
+ let requested = references_controller_test_model("file:///requested.mbt")
+ let wrong = references_controller_test_model("file:///wrong.mbt")
+ defer source.dispose()
+ defer requested.dispose()
+ defer wrong.dispose()
+ let releases = Ref(0)
+ let saw_cancelled = Ref(false)
+ let resolver = @navigation_api.TextModelResolverHandle(resolve=(
+ _resource,
+ token,
+ ) => {
+ saw_cancelled.val = token.is_cancellation_requested()
+ Some(TextModelReference(wrong, release=() => releases.val += 1))
+ })
+ let surface_generation = Ref(5)
+ let host = references_controller_test_host(source, surface_generation)
+ let previews = ReferencesControllerTestPreviewLog()
+ let tasks = ReferencesControllerTestTasks()
+ let controller = ReferencesController(
+ host.host(),
+ previews.factory(),
+ resolver~,
+ task => tasks.schedule(task),
+ )
+ let location : @language.Location = {
+ uri: requested.uri,
+ range: Range(1, 5, 1, 11),
+ }
+ assert_true(
+ controller.toggle_locations(
+ references_controller_test_anchor(source, surface_generation.val),
+ Definitions,
+ [location],
+ ),
+ )
+ controller.close(restore_focus=false)
+ tasks.run_all()
+ assert_true(saw_cancelled.val)
+ assert_eq(releases.val, 2)
+ assert_eq(previews.resources.length(), 0)
+ assert_eq(controller.phase(), PeekClosed)
+
+ assert_true(
+ controller.toggle_locations(
+ references_controller_test_anchor(source, surface_generation.val),
+ Definitions,
+ [location],
+ ),
+ )
+ tasks.run_all()
+ assert_eq(releases.val, 4)
+ assert_eq(previews.resources.length(), 0)
+ assert_true(controller.phase() is PeekUnavailable(_, _))
+ browser.flush_animation_frames()
+ controller.dispose()
+}
diff --git a/editor/internal/viewer/contrib/references/browser/references_peek_widget_reference_wbtest.mbt b/editor/internal/viewer/contrib/references/browser/references_peek_widget_reference_wbtest.mbt
index 1f1ff7fb6..5f4ca3bea 100644
--- a/editor/internal/viewer/contrib/references/browser/references_peek_widget_reference_wbtest.mbt
+++ b/editor/internal/viewer/contrib/references/browser/references_peek_widget_reference_wbtest.mbt
@@ -22,6 +22,7 @@ extern "js" fn install_references_peek_test_browser() -> ReferencesPeekBrowserTe
#| HTMLElement: globalThis.HTMLElement,
#| Event: globalThis.Event,
#| KeyboardEvent: globalThis.KeyboardEvent,
+ #| requestAnimationFrame: globalThis.requestAnimationFrame,
#| };
#|
#| class TestEvent {
@@ -208,7 +209,12 @@ extern "js" fn install_references_peek_test_browser() -> ReferencesPeekBrowserTe
#| globalThis.HTMLElement = TestElement;
#| globalThis.Event = TestEvent;
#| globalThis.KeyboardEvent = TestKeyboardEvent;
- #| return { originals, disposed: false };
+ #| const animationFrames = [];
+ #| globalThis.requestAnimationFrame = callback => {
+ #| animationFrames.push(callback);
+ #| return animationFrames.length;
+ #| };
+ #| return { originals, animationFrames, disposed: false };
#| }
///|
@@ -227,6 +233,21 @@ extern "js" fn ReferencesPeekBrowserTestHarness::dispose(
#| restore("HTMLElement", harness.originals.HTMLElement);
#| restore("Event", harness.originals.Event);
#| restore("KeyboardEvent", harness.originals.KeyboardEvent);
+ #| restore(
+ #| "requestAnimationFrame",
+ #| harness.originals.requestAnimationFrame,
+ #| );
+ #| }
+
+///|
+extern "js" fn ReferencesPeekBrowserTestHarness::flush_animation_frames(
+ self : ReferencesPeekBrowserTestHarness,
+) -> Unit =
+ #| harness => {
+ #| while (harness.animationFrames.length > 0) {
+ #| const callbacks = harness.animationFrames.splice(0);
+ #| for (const callback of callbacks) callback(0);
+ #| }
#| }
///|
diff --git a/editor/internal/viewer/diff_editor/alignment_geometry.mbt b/editor/internal/viewer/diff_editor/alignment_geometry.mbt
new file mode 100644
index 000000000..ea9d328c1
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/alignment_geometry.mbt
@@ -0,0 +1,454 @@
+// Pure pixel geometry for diff-pane alignment. The pane producer captures
+// actual model-line heights and foreign zone heights after excluding all
+// managed alignment zones. This engine only compares immutable numeric axes.
+
+///|
+/// An exact rendered height override for one model line. Wrapped lines use a
+/// height greater than `line_height`; hidden lines are represented separately
+/// and always resolve to zero height.
+pub(all) struct PaneModelLineHeight {
+ line_number : Int
+ height_in_px : Double
+} derive(Eq, Debug)
+
+///|
+/// Extra geometry after a model line. `after_line_number == 0` represents top
+/// padding before line 1. The snapshot producer must omit zones owned by the
+/// current alignment transaction.
+pub(all) struct PaneForeignZoneHeight {
+ after_line_number : Int
+ height_in_px : Double
+} derive(Eq, Debug)
+
+///|
+/// Immutable geometry for one pane and one render generation. The cumulative
+/// axis stores pixels after each model-line boundary, including foreign zones.
+pub struct PaneGeometrySnapshot {
+ line_height : Double
+ viewport_width : Double
+ line_heights : Array[Double]
+ line_max_columns : Array[Int]?
+ foreign_zone_heights : Array[PaneForeignZoneHeight]
+ cumulative_pixels : Array[Double]
+}
+
+///|
+fn normalized_foreign_zone_heights(
+ line_count : Int,
+ zones : Array[PaneForeignZoneHeight],
+) -> Array[PaneForeignZoneHeight] {
+ let heights = Array::make(line_count + 1, 0.0)
+ for zone in zones {
+ if zone.after_line_number >= 0 &&
+ zone.after_line_number <= line_count &&
+ zone.height_in_px > 0.0 {
+ heights[zone.after_line_number] += zone.height_in_px
+ }
+ }
+ let result : Array[PaneForeignZoneHeight] = []
+ for boundary in 0..<=line_count {
+ if heights[boundary] > 0.0 {
+ result.push({
+ after_line_number: boundary,
+ height_in_px: heights[boundary],
+ })
+ }
+ }
+ result
+}
+
+///|
+/// Builds a pane axis. `model_line_heights` are exact heights, not deltas;
+/// `hidden_lines` are applied last so a hidden wrapped line still contributes
+/// zero pixels. `line_max_columns` is optional and only enables the secondary
+/// synchronization point after a character-level change.
+pub fn PaneGeometrySnapshot::PaneGeometrySnapshot(
+ line_count : Int,
+ line_height : Double,
+ model_line_heights? : Array[PaneModelLineHeight] = [],
+ hidden_lines? : Array[@base_common.LineRange] = [],
+ foreign_zone_heights? : Array[PaneForeignZoneHeight] = [],
+ line_max_columns? : Array[Int],
+ viewport_width? : Double = 0.0,
+) -> PaneGeometrySnapshot {
+ let line_count = line_count.max(0)
+ let line_height = line_height.max(0.0)
+ let line_heights = Array::make(line_count, line_height)
+ for item in model_line_heights {
+ if item.line_number >= 1 && item.line_number <= line_count {
+ line_heights[item.line_number - 1] = item.height_in_px.max(0.0)
+ }
+ }
+ for hidden in hidden_lines {
+ let start = hidden.start_line_number.max(1)
+ let end_exclusive = hidden.end_line_number_exclusive.min(line_count + 1)
+ for line_number in start.. columns.copy()),
+ foreign_zone_heights,
+ cumulative_pixels,
+ }
+}
+
+///|
+pub fn PaneGeometrySnapshot::viewport_width(
+ self : PaneGeometrySnapshot,
+) -> Double {
+ self.viewport_width
+}
+
+///|
+pub fn PaneGeometrySnapshot::line_height(self : PaneGeometrySnapshot) -> Double {
+ self.line_height
+}
+
+///|
+pub fn PaneGeometrySnapshot::line_count(self : PaneGeometrySnapshot) -> Int {
+ self.line_heights.length()
+}
+
+///|
+pub fn PaneGeometrySnapshot::content_height(
+ self : PaneGeometrySnapshot,
+) -> Double {
+ self.cumulative_pixels[self.cumulative_pixels.length() - 1]
+}
+
+///|
+pub fn PaneGeometrySnapshot::foreign_zone_heights(
+ self : PaneGeometrySnapshot,
+) -> Array[PaneForeignZoneHeight] {
+ self.foreign_zone_heights.copy()
+}
+
+///|
+fn PaneGeometrySnapshot::pixels_after_line(
+ self : PaneGeometrySnapshot,
+ after_line_number : Int,
+) -> Double {
+ let boundary = after_line_number.clamp(min=0, max=self.line_count())
+ self.cumulative_pixels[boundary]
+}
+
+///|
+fn PaneGeometrySnapshot::max_column(
+ self : PaneGeometrySnapshot,
+ line_number : Int,
+) -> Int? {
+ guard self.line_max_columns is Some(columns) else { return None }
+ if line_number < 1 || line_number > columns.length() {
+ None
+ } else {
+ Some(columns[line_number - 1])
+ }
+}
+
+///|
+fn PaneGeometrySnapshot::has_top_foreign_zone(
+ self : PaneGeometrySnapshot,
+) -> Bool {
+ !self.foreign_zone_heights.is_empty() &&
+ self.foreign_zone_heights[0].after_line_number == 0
+}
+
+///|
+/// A zone specification applied by the owning widget. A zero anchor places a
+/// spacer before line 1, matching the editor's managed-zone coordinate system.
+pub(all) struct ManagedAlignmentSpacer {
+ after_line_number : Int
+ height_in_px : Double
+} derive(Eq, Debug)
+
+///|
+pub struct ManagedAlignmentSpacers {
+ original : Array[ManagedAlignmentSpacer]
+ modified : Array[ManagedAlignmentSpacer]
+}
+
+///|
+pub fn ManagedAlignmentSpacers::original(
+ self : ManagedAlignmentSpacers,
+) -> Array[ManagedAlignmentSpacer] {
+ self.original.copy()
+}
+
+///|
+pub fn ManagedAlignmentSpacers::modified(
+ self : ManagedAlignmentSpacers,
+) -> Array[ManagedAlignmentSpacer] {
+ self.modified.copy()
+}
+
+///|
+fn append_managed_spacer(
+ target : Array[ManagedAlignmentSpacer],
+ after_line_number : Int,
+ height_in_px : Double,
+) -> Unit {
+ if height_in_px <= 0.01 {
+ return
+ }
+ if !target.is_empty() &&
+ target[target.length() - 1].after_line_number == after_line_number {
+ let previous = target[target.length() - 1]
+ target[target.length() - 1] = {
+ after_line_number,
+ height_in_px: previous.height_in_px + height_in_px,
+ }
+ } else {
+ target.push({ after_line_number, height_in_px })
+ }
+}
+
+///|
+fn canonical_change_for_anchor(
+ projection : DiffFeatureProjection,
+ anchor : @diff.LineRangeMapping,
+) -> @diff.DetailedLineRangeMapping? {
+ for index in 0.. Array[Int] {
+ let boundaries : Array[Int] = []
+ if geometry.has_top_foreign_zone() {
+ boundaries.push(0)
+ }
+ // Every model-line boundary is a synchronization candidate. This makes
+ // arbitrary wrapping, hidden lines, and even differing base line heights
+ // exact without storing callbacks in the geometry snapshot.
+ for line_number in 1..<=geometry.line_count() {
+ boundaries.push(line_number)
+ }
+ boundaries
+}
+
+///|
+fn drop_consumed_events(
+ state : AlignmentBoundaryState,
+ original_events : Array[Int],
+ modified_events : Array[Int],
+) -> Unit {
+ while state.original_event_index < original_events.length() &&
+ original_events[state.original_event_index] <= state.original_boundary {
+ state.original_event_index += 1
+ }
+ while state.modified_event_index < modified_events.length() &&
+ modified_events[state.modified_event_index] <= state.modified_boundary {
+ state.modified_event_index += 1
+ }
+}
+
+///|
+/// Computes managed spacers from the projection's canonical changes and
+/// geometry-only anchors. Additional alignments participate because they are
+/// present in `alignment_anchors`, but they have no path to visual features.
+pub fn compute_managed_alignment_spacers(
+ projection : DiffFeatureProjection,
+ original_geometry : PaneGeometrySnapshot,
+ modified_geometry : PaneGeometrySnapshot,
+ inner_hunk_alignment? : Bool = true,
+) -> ManagedAlignmentSpacers {
+ let original_spacers : Array[ManagedAlignmentSpacer] = []
+ let modified_spacers : Array[ManagedAlignmentSpacer] = []
+ let original_events = alignment_event_boundaries(original_geometry)
+ let modified_events = alignment_event_boundaries(modified_geometry)
+ let state : AlignmentBoundaryState = {
+ original_boundary: 0,
+ modified_boundary: 0,
+ original_pixels: 0.0,
+ modified_pixels: 0.0,
+ original_event_index: 0,
+ modified_event_index: 0,
+ }
+
+ let emit_alignment = fn(original_boundary : Int, modified_boundary : Int) {
+ if original_boundary < state.original_boundary ||
+ modified_boundary < state.modified_boundary ||
+ original_boundary > original_geometry.line_count() ||
+ modified_boundary > modified_geometry.line_count() {
+ return
+ }
+ let original_end_pixels = original_geometry.pixels_after_line(
+ original_boundary,
+ )
+ let modified_end_pixels = modified_geometry.pixels_after_line(
+ modified_boundary,
+ )
+ let original_height = original_end_pixels - state.original_pixels
+ let modified_height = modified_end_pixels - state.modified_pixels
+ let delta = modified_height - original_height
+ if delta > 0.01 {
+ append_managed_spacer(original_spacers, original_boundary, delta)
+ } else if delta < -0.01 {
+ append_managed_spacer(modified_spacers, modified_boundary, -delta)
+ }
+ state.original_boundary = original_boundary
+ state.modified_boundary = modified_boundary
+ state.original_pixels = original_end_pixels
+ state.modified_pixels = modified_end_pixels
+ }
+
+ let handle_outside_anchors = fn(
+ until_original_boundary : Int,
+ until_modified_boundary : Int,
+ ) {
+ while true {
+ let original_next = if state.original_event_index <
+ original_events.length() &&
+ original_events[state.original_event_index] <= until_original_boundary {
+ Some(original_events[state.original_event_index])
+ } else {
+ None
+ }
+ let modified_next = if state.modified_event_index <
+ modified_events.length() &&
+ modified_events[state.modified_event_index] <= until_modified_boundary {
+ Some(modified_events[state.modified_event_index])
+ } else {
+ None
+ }
+ if original_next is None && modified_next is None {
+ break
+ }
+ let original_distance = match original_next {
+ Some(boundary) => boundary - state.original_boundary
+ None => 2147483647
+ }
+ let modified_distance = match modified_next {
+ Some(boundary) => boundary - state.modified_boundary
+ None => 2147483647
+ }
+ let (original_boundary, modified_boundary) = if original_distance <
+ modified_distance {
+ guard original_next is Some(boundary) else { break }
+ state.original_event_index += 1
+ (boundary, state.modified_boundary + original_distance)
+ } else if original_distance > modified_distance {
+ guard modified_next is Some(boundary) else { break }
+ state.modified_event_index += 1
+ (state.original_boundary + modified_distance, boundary)
+ } else {
+ guard (original_next, modified_next) is (Some(original), Some(modified)) else {
+ break
+ }
+ state.original_event_index += 1
+ state.modified_event_index += 1
+ (original, modified)
+ }
+ emit_alignment(original_boundary, modified_boundary)
+ }
+ }
+
+ for anchor in projection.alignment_anchors() {
+ let original_start_boundary = anchor.original.start_line_number - 1
+ let modified_start_boundary = anchor.modified.start_line_number - 1
+ let original_end_boundary = anchor.original.end_line_number_exclusive - 1
+ let modified_end_boundary = anchor.modified.end_line_number_exclusive - 1
+ if original_start_boundary < state.original_boundary ||
+ modified_start_boundary < state.modified_boundary ||
+ original_end_boundary < original_start_boundary ||
+ modified_end_boundary < modified_start_boundary {
+ continue
+ }
+ handle_outside_anchors(original_start_boundary, modified_start_boundary)
+ // If sparse geometry had no event at the start, establish the unchanged
+ // prefix checkpoint before entering the hunk/alignment range.
+ emit_alignment(original_start_boundary, modified_start_boundary)
+ if inner_hunk_alignment {
+ match canonical_change_for_anchor(projection, anchor) {
+ Some(change) =>
+ match change.inner_changes {
+ Some(inner_changes) =>
+ for inner in inner_changes {
+ if inner.original.start_column > 1 &&
+ inner.modified.start_column > 1 {
+ emit_alignment(
+ inner.original.start_line_number - 1,
+ inner.modified.start_line_number - 1,
+ )
+ }
+ match
+ original_geometry.max_column(inner.original.end_line_number) {
+ Some(max_column) if inner.original.end_column < max_column =>
+ emit_alignment(
+ inner.original.end_line_number - 1,
+ inner.modified.end_line_number - 1,
+ )
+ _ => ()
+ }
+ }
+ None => ()
+ }
+ None => ()
+ }
+ }
+ emit_alignment(original_end_boundary, modified_end_boundary)
+ drop_consumed_events(state, original_events, modified_events)
+ }
+
+ // The final model-line boundary is handled by the same geometry transaction
+ // as every other unchanged boundary. A wrapped/hidden/foreign-zone delta on
+ // the final line therefore produces an ordinary managed spacer, not a
+ // residual content-height correction.
+ handle_outside_anchors(
+ original_geometry.line_count(),
+ modified_geometry.line_count(),
+ )
+ emit_alignment(original_geometry.line_count(), modified_geometry.line_count())
+ { original: original_spacers, modified: modified_spacers }
+}
+
+///|
+pub extend ManagedAlignmentSpacer with Debug::{to_repr}
+
+///|
+pub extend ManagedAlignmentSpacer with Eq::{equal, not_equal}
+
+///|
+pub extend PaneForeignZoneHeight with Debug::{to_repr}
+
+///|
+pub extend PaneForeignZoneHeight with Eq::{equal, not_equal}
+
+///|
+pub extend PaneModelLineHeight with Debug::{to_repr}
+
+///|
+pub extend PaneModelLineHeight with Eq::{equal, not_equal}
diff --git a/editor/internal/viewer/diff_editor/alignment_geometry_wbtest.mbt b/editor/internal/viewer/diff_editor/alignment_geometry_wbtest.mbt
new file mode 100644
index 000000000..4c7690816
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/alignment_geometry_wbtest.mbt
@@ -0,0 +1,193 @@
+///|
+fn alignment_test_text(line_count : Int) -> String {
+ let lines : Array[String] = []
+ for _index in 0.. DiffFeatureProjection {
+ DiffFeatureProjection(
+ { changes, additional_alignments },
+ TextSnapshot(alignment_test_text(original_line_count)),
+ TextSnapshot(alignment_test_text(modified_line_count)),
+ )
+}
+
+///|
+fn alignment_test_change(
+ original_start : Int,
+ original_end : Int,
+ modified_start : Int,
+ modified_end : Int,
+ inner_changes? : Array[@diff.RangeMapping],
+) -> @diff.DetailedLineRangeMapping {
+ DetailedLineRangeMapping(
+ LineRange(original_start, original_end),
+ LineRange(modified_start, modified_end),
+ inner_changes,
+ )
+}
+
+///|
+test "geometry alignment handles unequal insert delete and replace hunks" {
+ let projection = alignment_test_projection(
+ [
+ alignment_test_change(3, 3, 3, 5),
+ alignment_test_change(8, 11, 10, 10),
+ alignment_test_change(14, 16, 13, 17),
+ ],
+ [],
+ 16,
+ 17,
+ )
+ let spacers = compute_managed_alignment_spacers(
+ projection,
+ PaneGeometrySnapshot(16, 20.0),
+ PaneGeometrySnapshot(17, 20.0),
+ )
+ assert_eq(spacers.original(), [
+ { after_line_number: 2, height_in_px: 40.0 },
+ { after_line_number: 15, height_in_px: 40.0 },
+ ])
+ assert_eq(spacers.modified(), [{ after_line_number: 9, height_in_px: 60.0 }])
+}
+
+///|
+test "wrapped model lines align from exact per-line pixel heights" {
+ let projection = alignment_test_projection([], [], 4, 4)
+ let spacers = compute_managed_alignment_spacers(
+ projection,
+ PaneGeometrySnapshot(4, 20.0, model_line_heights=[
+ { line_number: 2, height_in_px: 40.0 },
+ ]),
+ PaneGeometrySnapshot(4, 20.0),
+ )
+ assert_true(spacers.original().is_empty())
+ assert_eq(spacers.modified(), [{ after_line_number: 2, height_in_px: 20.0 }])
+}
+
+///|
+test "foreign zones align unchanged regions at their model boundaries" {
+ let projection = alignment_test_projection([], [], 10, 10)
+ let spacers = compute_managed_alignment_spacers(
+ projection,
+ PaneGeometrySnapshot(10, 20.0, foreign_zone_heights=[
+ { after_line_number: 3, height_in_px: 30.0 },
+ { after_line_number: 8, height_in_px: 5.0 },
+ ]),
+ PaneGeometrySnapshot(10, 20.0, foreign_zone_heights=[
+ { after_line_number: 3, height_in_px: 10.0 },
+ { after_line_number: 6, height_in_px: 25.0 },
+ ]),
+ )
+ assert_eq(spacers.original(), [{ after_line_number: 6, height_in_px: 25.0 }])
+ assert_eq(spacers.modified(), [
+ { after_line_number: 3, height_in_px: 20.0 },
+ { after_line_number: 8, height_in_px: 5.0 },
+ ])
+}
+
+///|
+test "hidden lines and replacement foreign zones both affect geometry" {
+ let projection = alignment_test_projection([], [], 4, 4)
+ let hidden_only = compute_managed_alignment_spacers(
+ projection,
+ PaneGeometrySnapshot(4, 20.0, hidden_lines=[LineRange(2, 3)]),
+ PaneGeometrySnapshot(4, 20.0),
+ )
+ assert_eq(hidden_only.original(), [
+ { after_line_number: 2, height_in_px: 20.0 },
+ ])
+ assert_true(hidden_only.modified().is_empty())
+
+ let hidden_with_zone = compute_managed_alignment_spacers(
+ projection,
+ PaneGeometrySnapshot(4, 20.0, hidden_lines=[LineRange(2, 3)], foreign_zone_heights=[
+ { after_line_number: 2, height_in_px: 50.0 },
+ ]),
+ PaneGeometrySnapshot(4, 20.0),
+ )
+ assert_true(hidden_with_zone.original().is_empty())
+ assert_eq(hidden_with_zone.modified(), [
+ { after_line_number: 2, height_in_px: 30.0 },
+ ])
+}
+
+///|
+test "additional alignment can align omitted geometry without a real change" {
+ let additional = @diff.LineRangeMapping(LineRange(2, 2), LineRange(2, 3))
+ let projection = alignment_test_projection([], [additional], 3, 4)
+ assert_eq(projection.change_count(), 0)
+ let spacers = compute_managed_alignment_spacers(
+ projection,
+ PaneGeometrySnapshot(3, 20.0),
+ PaneGeometrySnapshot(4, 20.0),
+ )
+ assert_eq(spacers.original(), [{ after_line_number: 1, height_in_px: 20.0 }])
+ assert_true(spacers.modified().is_empty())
+}
+
+///|
+test "character mappings add synchronization points inside a real hunk" {
+ let original_inner = @base_common.Range(2, 5, 3, 4)
+ let modified_inner = @base_common.Range(2, 5, 4, 4)
+ let change = alignment_test_change(2, 6, 2, 8, inner_changes=[
+ RangeMapping(original_inner, modified_inner),
+ ])
+ let projection = alignment_test_projection([change], [], 7, 9)
+ let spacers = compute_managed_alignment_spacers(
+ projection,
+ PaneGeometrySnapshot(7, 20.0, line_max_columns=Array::make(7, 100)),
+ PaneGeometrySnapshot(9, 20.0),
+ )
+ assert_eq(spacers.original(), [
+ { after_line_number: 2, height_in_px: 20.0 },
+ { after_line_number: 5, height_in_px: 20.0 },
+ ])
+ assert_true(spacers.modified().is_empty())
+}
+
+///|
+test "final-line geometry is an ordinary boundary not a tail correction" {
+ let projection = alignment_test_projection([], [], 3, 3)
+ let original = PaneGeometrySnapshot(
+ 3,
+ 20.0,
+ model_line_heights=[{ line_number: 3, height_in_px: 60.0 }],
+ foreign_zone_heights=[{ after_line_number: 3, height_in_px: 10.0 }],
+ )
+ let modified = PaneGeometrySnapshot(3, 20.0)
+ let spacers = compute_managed_alignment_spacers(
+ projection, original, modified,
+ )
+ assert_true(spacers.original().is_empty())
+ assert_eq(spacers.modified(), [{ after_line_number: 3, height_in_px: 50.0 }])
+ assert_eq(
+ original.content_height(),
+ modified.content_height() + spacers.modified()[0].height_in_px,
+ )
+}
+
+///|
+test "snapshot normalizes duplicate foreign zones and excludes invalid input" {
+ let geometry = PaneGeometrySnapshot(3, 20.0, foreign_zone_heights=[
+ { after_line_number: 0, height_in_px: 7.0 },
+ { after_line_number: 2, height_in_px: 8.0 },
+ { after_line_number: 2, height_in_px: 12.0 },
+ { after_line_number: 4, height_in_px: 99.0 },
+ { after_line_number: 1, height_in_px: -3.0 },
+ ])
+ assert_eq(geometry.foreign_zone_heights(), [
+ { after_line_number: 0, height_in_px: 7.0 },
+ { after_line_number: 2, height_in_px: 20.0 },
+ ])
+ assert_eq(geometry.content_height(), 87.0)
+}
diff --git a/editor/internal/viewer/diff_editor/editors.mbt b/editor/internal/viewer/diff_editor/editors.mbt
new file mode 100644
index 000000000..e36e355af
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/editors.mbt
@@ -0,0 +1,212 @@
+///|
+/// Owns the two ordinary code-editor kernels used by one diff editor. This is
+/// the local counterpart of VS Code's DiffEditorEditors: pane construction,
+/// option mapping, model-pair installation, focus, layout, and disposal are
+/// centralized here instead of being duplicated by individual diff features.
+pub struct DiffEditorEditors {
+ original : @code_widget.CodeEditorWidget
+ modified : @code_widget.CodeEditorWidget
+ owned_services : @code_widget.CodeEditorServices?
+ model_pair_commit : DiffEditorModelPairCommitBarrier
+ mut disposed : Bool
+}
+
+///|
+/// The explicit transaction boundary around the two synchronous kernel
+/// `set_model` calls. `committed_generation` advances only after both panes
+/// contain the same requested pair, so no diff-owned observer can ever treat a
+/// half-installed pair as committed state.
+struct DiffEditorModelPairCommitBarrier {
+ mut is_committing : Bool
+ mut committed_generation : Int
+}
+
+///|
+fn new_diff_editor_model_pair_commit_barrier() -> DiffEditorModelPairCommitBarrier {
+ { is_committing: false, committed_generation: 0 }
+}
+
+///|
+fn DiffEditorEditors::create(
+ original_host : @rdom.Element,
+ modified_host : @rdom.Element,
+ services? : @code_widget.CodeEditorServices,
+ editor_options : @code_widget.CodeEditorOptions,
+ diff_word_wrap : DiffWordWrap,
+) -> DiffEditorEditors {
+ let (shared_services, owned_services) = match services {
+ Some(services) => (services, None)
+ None => {
+ let services = @code_widget.CodeEditorServices()
+ (services, Some(services))
+ }
+ }
+ let pane_options = match diff_word_wrap {
+ Inherit => editor_options
+ Off => editor_options.with_soft_wrap(false)
+ }
+ {
+ original: @code_widget.CodeEditorWidget::create(
+ original_host,
+ services=shared_services,
+ options=pane_options,
+ contributions=@code_widget.CodeEditorContributionDescriptors::diff_pane(),
+ ),
+ modified: @code_widget.CodeEditorWidget::create(
+ modified_host,
+ services=shared_services,
+ options=pane_options,
+ contributions=@code_widget.CodeEditorContributionDescriptors::diff_pane(),
+ ),
+ owned_services,
+ model_pair_commit: new_diff_editor_model_pair_commit_barrier(),
+ disposed: false,
+ }
+}
+
+///|
+fn DiffEditorEditors::same_model(
+ self : DiffEditorEditors,
+ model : DiffEditorModelData?,
+) -> Bool {
+ match (self.original.get_model(), self.modified.get_model(), model) {
+ (None, None, None) => true
+ (Some(current_original), Some(current_modified), Some(next)) =>
+ current_original.same(next.original) &&
+ current_modified.same(next.modified)
+ _ => false
+ }
+}
+
+///|
+fn DiffEditorEditors::set_model(
+ self : DiffEditorEditors,
+ model : DiffEditorModelData?,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ if self.model_pair_commit.is_committing {
+ // Diff-level callbacks are observers of the transaction, never a second
+ // model-pair owner. Reject accidental internal reentry instead of adding a
+ // scheduler or a second pair queue.
+ return
+ }
+ if self.same_model(model) {
+ return
+ }
+ commit_diff_editor_model_pair_transaction(self.model_pair_commit, () => {
+ commit_diff_editor_model_pair(
+ self.model_pair_commit,
+ model,
+ model => self.original.set_model(model),
+ model => self.modified.set_model(model),
+ )
+ })
+}
+
+///|
+fn commit_diff_editor_model_pair_transaction(
+ barrier : DiffEditorModelPairCommitBarrier,
+ body : () -> Unit,
+) -> Unit {
+ if barrier.is_committing {
+ return
+ }
+ barrier.is_committing = true
+ body()
+ barrier.is_committing = false
+}
+
+///|
+fn commit_diff_editor_model_pair(
+ barrier : DiffEditorModelPairCommitBarrier,
+ model : DiffEditorModelData?,
+ set_original : (@model.TextModel?) -> Unit,
+ set_modified : (@model.TextModel?) -> Unit,
+) -> Unit {
+ match model {
+ Some(model) => {
+ set_original(Some(model.original))
+ set_modified(Some(model.modified))
+ }
+ None => {
+ set_original(None)
+ set_modified(None)
+ }
+ }
+ // This is the commit publication point: both synchronous setters have
+ // returned while the barrier remains closed to observers.
+ barrier.committed_generation += 1
+}
+
+///|
+fn DiffEditorEditors::is_committing_model_pair(
+ self : DiffEditorEditors,
+) -> Bool {
+ self.model_pair_commit.is_committing
+}
+
+///|
+fn DiffEditorEditors::committed_model_generation(
+ self : DiffEditorEditors,
+) -> Int {
+ self.model_pair_commit.committed_generation
+}
+
+///|
+fn DiffEditorEditors::update_options(
+ self : DiffEditorEditors,
+ editor_options : @code_widget.CodeEditorOptions,
+ diff_word_wrap : DiffWordWrap,
+ layout : DiffEditorLayout,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ let pane_options = match diff_word_wrap {
+ Inherit => editor_options
+ Off => editor_options.with_soft_wrap(false)
+ }
+ // Inline's original pane is display:none. Keep its last valid geometry and
+ // apply the latest options when it becomes visible again instead of letting
+ // a hidden kernel consume a fallback measurement.
+ if layout == SideBySide {
+ self.original.update_options(pane_options)
+ }
+ self.modified.update_options(pane_options)
+}
+
+///|
+fn DiffEditorEditors::layout(
+ self : DiffEditorEditors,
+ layout : DiffEditorLayout,
+) -> Unit {
+ if !self.disposed {
+ if layout == SideBySide {
+ self.original.layout()
+ }
+ self.modified.layout()
+ }
+}
+
+///|
+fn DiffEditorEditors::focus(self : DiffEditorEditors) -> Unit {
+ if !self.disposed {
+ self.modified.focus()
+ }
+}
+
+///|
+fn DiffEditorEditors::dispose(self : DiffEditorEditors) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.original.dispose()
+ self.modified.dispose()
+ match self.owned_services {
+ Some(services) => services.dispose()
+ None => ()
+ }
+}
diff --git a/editor/internal/viewer/diff_editor/feature_projection.mbt b/editor/internal/viewer/diff_editor/feature_projection.mbt
new file mode 100644
index 000000000..636b860ef
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/feature_projection.mbt
@@ -0,0 +1,347 @@
+// Renderer-neutral projections derived from a validated DocumentDiff.
+//
+// A DetailedLineRangeMapping remains the only representation of a real diff
+// change. This file deliberately does not introduce a second viewer-specific
+// change model. Instead, each feature stores only the small piece of derived
+// data it needs. In particular, additional_alignments are appended only to
+// the geometry anchors and can never become decorations, overview entries,
+// or navigation targets.
+
+///|
+/// Decorations for one editor pane. Whole-line ranges cover the actual UTF-16
+/// extent of their final model line; inner ranges retain the provider's
+/// one-based UTF-16 coordinates unchanged.
+pub struct DiffPaneDecorations {
+ whole_line : Array[@base_common.Range]
+ inner : Array[@base_common.Range]
+}
+
+///|
+pub fn DiffPaneDecorations::whole_line_ranges(
+ self : DiffPaneDecorations,
+) -> Array[@base_common.Range] {
+ self.whole_line.copy()
+}
+
+///|
+pub fn DiffPaneDecorations::inner_ranges(
+ self : DiffPaneDecorations,
+) -> Array[@base_common.Range] {
+ self.inner.copy()
+}
+
+///|
+/// One overview-ruler entry. `change_index` addresses the projection's sorted
+/// change array, while `range` is the non-empty range rendered by this pane.
+pub(all) struct DiffOverviewEntry {
+ change_index : Int
+ range : @base_common.LineRange
+} derive(Eq, Debug)
+
+///|
+/// A cyclic F7 navigation result. The mapping is the canonical provider
+/// mapping rather than a viewer-specific copy of its fields.
+pub(all) struct DiffNavigationTarget {
+ change_index : Int
+ change_count : Int
+ change : @diff.DetailedLineRangeMapping
+} derive(Eq, Debug)
+
+///|
+/// Pure feature data for one diff generation. Consumers apply these values in
+/// the transaction order owned by DiffEditorWidget; this type owns no model,
+/// browser node, scheduler, or rendering lifecycle.
+pub struct DiffFeatureProjection {
+ sorted_changes : Array[@diff.DetailedLineRangeMapping]
+ original_decorations : DiffPaneDecorations
+ modified_decorations : DiffPaneDecorations
+ original_overview : Array[DiffOverviewEntry]
+ modified_overview : Array[DiffOverviewEntry]
+ alignment_anchors : Array[@diff.LineRangeMapping]
+}
+
+///|
+priv struct IndexedChange {
+ source_index : Int
+ change : @diff.DetailedLineRangeMapping
+}
+
+///|
+fn compare_line_ranges(
+ left : @base_common.LineRange,
+ right : @base_common.LineRange,
+) -> Int {
+ let start_order = left.start_line_number - right.start_line_number
+ if start_order != 0 {
+ return start_order
+ }
+ left.end_line_number_exclusive - right.end_line_number_exclusive
+}
+
+///|
+fn compare_mappings(
+ left : @diff.LineRangeMapping,
+ right : @diff.LineRangeMapping,
+) -> Int {
+ let original_order = compare_line_ranges(left.original, right.original)
+ if original_order != 0 {
+ return original_order
+ }
+ compare_line_ranges(left.modified, right.modified)
+}
+
+///|
+fn compare_indexed_changes(left : IndexedChange, right : IndexedChange) -> Int {
+ let mapping_order = compare_mappings(
+ left.change.line_mapping(),
+ right.change.line_mapping(),
+ )
+ if mapping_order != 0 {
+ return mapping_order
+ }
+ // Array::sort_by is unstable. Preserve provider order for identical ranges.
+ left.source_index - right.source_index
+}
+
+///|
+priv struct IndexedAlignment {
+ source_index : Int
+ mapping : @diff.LineRangeMapping
+}
+
+///|
+fn compare_indexed_alignments(
+ left : IndexedAlignment,
+ right : IndexedAlignment,
+) -> Int {
+ let mapping_order = compare_mappings(left.mapping, right.mapping)
+ if mapping_order != 0 {
+ return mapping_order
+ }
+ left.source_index - right.source_index
+}
+
+///|
+fn sorted_changes(
+ changes : Array[@diff.DetailedLineRangeMapping],
+) -> Array[@diff.DetailedLineRangeMapping] {
+ let indexed : Array[IndexedChange] = []
+ for source_index, change in changes {
+ indexed.push({ source_index, change })
+ }
+ indexed.sort_by(compare_indexed_changes)
+ indexed.map(item => item.change)
+}
+
+///|
+fn append_whole_line_range(
+ target : Array[@base_common.Range],
+ line_range : @base_common.LineRange,
+ snapshot : @model.TextSnapshot,
+) -> Unit {
+ // An empty side denotes an insertion/deletion anchor, not a physical source
+ // line. Likewise, an empty document's single cursor line is not changed
+ // content and must not receive a whole-line decoration.
+ if line_range.is_empty() || snapshot.get_value().is_empty() {
+ return
+ }
+ let end_line = line_range.end_line_number_exclusive - 1
+ target.push(
+ Range(
+ line_range.start_line_number,
+ 1,
+ end_line,
+ snapshot.get_line_max_column(end_line),
+ ),
+ )
+}
+
+///|
+fn append_inner_range(
+ target : Array[@base_common.Range],
+ range : @base_common.Range,
+) -> Unit {
+ if !range.is_empty() {
+ // Range columns are already one-based UTF-16 coordinates. Copy the value
+ // without converting through code points, bytes, or rendered columns.
+ target.push(range)
+ }
+}
+
+///|
+fn sorted_alignment_anchors(
+ changes : Array[@diff.DetailedLineRangeMapping],
+ additional_alignments : Array[@diff.LineRangeMapping],
+) -> Array[@diff.LineRangeMapping] {
+ let indexed : Array[IndexedAlignment] = []
+ for change in changes {
+ indexed.push({
+ source_index: indexed.length(),
+ mapping: change.line_mapping(),
+ })
+ }
+ for alignment in additional_alignments {
+ indexed.push({ source_index: indexed.length(), mapping: alignment })
+ }
+ indexed.sort_by(compare_indexed_alignments)
+ indexed.map(item => item.mapping)
+}
+
+///|
+/// Derives all renderer-neutral diff feature inputs from one immutable result.
+/// The input is expected to have passed DiffEditorViewModel validation.
+pub fn DiffFeatureProjection::DiffFeatureProjection(
+ document_diff : @diff.DocumentDiff,
+ original : @model.TextSnapshot,
+ modified : @model.TextSnapshot,
+) -> DiffFeatureProjection {
+ let changes = sorted_changes(document_diff.changes)
+ let original_whole_line : Array[@base_common.Range] = []
+ let modified_whole_line : Array[@base_common.Range] = []
+ let original_inner : Array[@base_common.Range] = []
+ let modified_inner : Array[@base_common.Range] = []
+ let original_overview : Array[DiffOverviewEntry] = []
+ let modified_overview : Array[DiffOverviewEntry] = []
+ for change_index, change in changes {
+ append_whole_line_range(original_whole_line, change.original, original)
+ append_whole_line_range(modified_whole_line, change.modified, modified)
+ if !change.original.is_empty() {
+ original_overview.push({ change_index, range: change.original })
+ }
+ if !change.modified.is_empty() {
+ modified_overview.push({ change_index, range: change.modified })
+ }
+ match change.inner_changes {
+ Some(inner_changes) =>
+ for inner_change in inner_changes {
+ append_inner_range(original_inner, inner_change.original)
+ append_inner_range(modified_inner, inner_change.modified)
+ }
+ None => ()
+ }
+ }
+ {
+ sorted_changes: changes,
+ original_decorations: {
+ whole_line: original_whole_line,
+ inner: original_inner,
+ },
+ modified_decorations: {
+ whole_line: modified_whole_line,
+ inner: modified_inner,
+ },
+ original_overview,
+ modified_overview,
+ alignment_anchors: sorted_alignment_anchors(
+ changes,
+ document_diff.additional_alignments,
+ ),
+ }
+}
+
+///|
+pub fn DiffFeatureProjection::change_count(self : DiffFeatureProjection) -> Int {
+ self.sorted_changes.length()
+}
+
+///|
+pub fn DiffFeatureProjection::change_at(
+ self : DiffFeatureProjection,
+ change_index : Int,
+) -> @diff.DetailedLineRangeMapping? {
+ if change_index < 0 || change_index >= self.sorted_changes.length() {
+ None
+ } else {
+ Some(self.sorted_changes[change_index])
+ }
+}
+
+///|
+pub fn DiffFeatureProjection::original_decorations(
+ self : DiffFeatureProjection,
+) -> DiffPaneDecorations {
+ self.original_decorations
+}
+
+///|
+pub fn DiffFeatureProjection::modified_decorations(
+ self : DiffFeatureProjection,
+) -> DiffPaneDecorations {
+ self.modified_decorations
+}
+
+///|
+pub fn DiffFeatureProjection::original_overview_entries(
+ self : DiffFeatureProjection,
+) -> Array[DiffOverviewEntry] {
+ self.original_overview.copy()
+}
+
+///|
+pub fn DiffFeatureProjection::modified_overview_entries(
+ self : DiffFeatureProjection,
+) -> Array[DiffOverviewEntry] {
+ self.modified_overview.copy()
+}
+
+///|
+/// Geometry anchors contain every real change plus every additional alignment.
+/// No other projection accessor observes additional_alignments.
+pub fn DiffFeatureProjection::alignment_anchors(
+ self : DiffFeatureProjection,
+) -> Array[@diff.LineRangeMapping] {
+ self.alignment_anchors.copy()
+}
+
+///|
+pub fn DiffFeatureProjection::next_change(
+ self : DiffFeatureProjection,
+ current_change_index : Int?,
+) -> DiffNavigationTarget? {
+ let count = self.sorted_changes.length()
+ if count == 0 {
+ return None
+ }
+ let index = match current_change_index {
+ Some(current) if current >= 0 && current < count => (current + 1) % count
+ _ => 0
+ }
+ Some({
+ change_index: index,
+ change_count: count,
+ change: self.sorted_changes[index],
+ })
+}
+
+///|
+pub fn DiffFeatureProjection::previous_change(
+ self : DiffFeatureProjection,
+ current_change_index : Int?,
+) -> DiffNavigationTarget? {
+ let count = self.sorted_changes.length()
+ if count == 0 {
+ return None
+ }
+ let index = match current_change_index {
+ Some(current) if current >= 0 && current < count =>
+ (current - 1 + count) % count
+ _ => count - 1
+ }
+ Some({
+ change_index: index,
+ change_count: count,
+ change: self.sorted_changes[index],
+ })
+}
+
+///|
+pub extend DiffNavigationTarget with Debug::{to_repr}
+
+///|
+pub extend DiffNavigationTarget with Eq::{equal, not_equal}
+
+///|
+pub extend DiffOverviewEntry with Debug::{to_repr}
+
+///|
+pub extend DiffOverviewEntry with Eq::{equal, not_equal}
diff --git a/editor/internal/viewer/diff_editor/feature_projection_wbtest.mbt b/editor/internal/viewer/diff_editor/feature_projection_wbtest.mbt
new file mode 100644
index 000000000..460cb18bd
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/feature_projection_wbtest.mbt
@@ -0,0 +1,164 @@
+///|
+fn projection_change(
+ original_start : Int,
+ original_end : Int,
+ modified_start : Int,
+ modified_end : Int,
+ inner_changes? : Array[@diff.RangeMapping],
+) -> @diff.DetailedLineRangeMapping {
+ DetailedLineRangeMapping(
+ LineRange(original_start, original_end),
+ LineRange(modified_start, modified_end),
+ inner_changes,
+ )
+}
+
+///|
+test "additional alignments are isolated to geometry anchors" {
+ let change = projection_change(1, 2, 1, 2)
+ let extra = @diff.LineRangeMapping(LineRange(2, 2), LineRange(2, 2))
+ let original = @model.TextSnapshot("before\ntail")
+ let modified = @model.TextSnapshot("after\ntail")
+ let without_extra = DiffFeatureProjection(
+ { changes: [change], additional_alignments: [] },
+ original,
+ modified,
+ )
+ let with_extra = DiffFeatureProjection(
+ { changes: [change], additional_alignments: [extra] },
+ original,
+ modified,
+ )
+
+ assert_eq(
+ with_extra.original_decorations().whole_line_ranges(),
+ without_extra.original_decorations().whole_line_ranges(),
+ )
+ assert_eq(
+ with_extra.modified_decorations().whole_line_ranges(),
+ without_extra.modified_decorations().whole_line_ranges(),
+ )
+ assert_eq(
+ with_extra.original_decorations().inner_ranges(),
+ without_extra.original_decorations().inner_ranges(),
+ )
+ assert_eq(
+ with_extra.modified_decorations().inner_ranges(),
+ without_extra.modified_decorations().inner_ranges(),
+ )
+ assert_eq(
+ with_extra.original_overview_entries(),
+ without_extra.original_overview_entries(),
+ )
+ assert_eq(
+ with_extra.modified_overview_entries(),
+ without_extra.modified_overview_entries(),
+ )
+ assert_eq(with_extra.change_count(), 1)
+ assert_eq(with_extra.next_change(None), without_extra.next_change(None))
+ assert_eq(with_extra.alignment_anchors(), [change.line_mapping(), extra])
+ assert_eq(without_extra.alignment_anchors(), [change.line_mapping()])
+}
+
+///|
+test "insert delete and modify project pane-specific decorations" {
+ let insert = projection_change(1, 1, 1, 2)
+ let original_inner = @base_common.Range(2, 2, 2, 4)
+ let modified_inner = @base_common.Range(2, 2, 2, 4)
+ let modify = projection_change(2, 3, 2, 3, inner_changes=[
+ RangeMapping(original_inner, modified_inner),
+ ])
+ let delete = projection_change(3, 4, 4, 4)
+ // Deliberately unsorted: projection order is independent of provider array
+ // order, and navigation/overview indices use the sorted canonical changes.
+ let projection = DiffFeatureProjection(
+ { changes: [delete, insert, modify], additional_alignments: [] },
+ TextSnapshot("keep\na😀b\ngone\ntail"),
+ TextSnapshot("new\naXYb\nkeep\ntail"),
+ )
+
+ // The emoji occupies two UTF-16 code units, so line 2's max column is 5.
+ assert_eq(projection.original_decorations().whole_line_ranges(), [
+ Range(2, 1, 2, 5),
+ Range(3, 1, 3, 5),
+ ])
+ assert_eq(projection.modified_decorations().whole_line_ranges(), [
+ Range(1, 1, 1, 4),
+ Range(2, 1, 2, 5),
+ ])
+ assert_eq(projection.original_decorations().inner_ranges(), [original_inner])
+ assert_eq(projection.modified_decorations().inner_ranges(), [modified_inner])
+
+ // Empty original/modified ranges remain alignment anchors but never create
+ // phantom whole-line or overview decorations on that pane.
+ assert_eq(projection.original_overview_entries(), [
+ { change_index: 1, range: LineRange(2, 3) },
+ { change_index: 2, range: LineRange(3, 4) },
+ ])
+ assert_eq(projection.modified_overview_entries(), [
+ { change_index: 0, range: LineRange(1, 2) },
+ { change_index: 1, range: LineRange(2, 3) },
+ ])
+ assert_eq(projection.alignment_anchors(), [
+ insert.line_mapping(),
+ modify.line_mapping(),
+ delete.line_mapping(),
+ ])
+ // Provider UTF-16 columns are copied exactly rather than converted through
+ // Unicode scalar values or bytes.
+ assert_eq(projection.change_at(1), Some(modify))
+ assert_eq(
+ projection.change_at(1).unwrap().inner_changes.unwrap()[0].original,
+ Range(2, 2, 2, 4),
+ )
+}
+
+///|
+test "navigation follows sorted changes and cycles in both directions" {
+ let first = projection_change(1, 1, 1, 2)
+ let second = projection_change(2, 3, 2, 3)
+ let third = projection_change(4, 5, 4, 4)
+ let projection = DiffFeatureProjection(
+ { changes: [third, second, first], additional_alignments: [] },
+ TextSnapshot("one\ntwo\nthree\nfour"),
+ TextSnapshot("added\ntwo\nthree\nfour"),
+ )
+
+ assert_eq(projection.change_at(0), Some(first))
+ assert_eq(projection.change_at(1), Some(second))
+ assert_eq(projection.change_at(2), Some(third))
+ assert_true(projection.change_at(-1) is None)
+ assert_true(projection.change_at(3) is None)
+
+ let next_first = projection.next_change(None).unwrap()
+ assert_eq(next_first.change_index, 0)
+ assert_eq(next_first.change_count, 3)
+ assert_eq(next_first.change, first)
+ assert_eq(projection.next_change(Some(0)).unwrap().change_index, 1)
+ assert_eq(projection.next_change(Some(1)).unwrap().change_index, 2)
+ assert_eq(projection.next_change(Some(2)).unwrap().change_index, 0)
+ assert_eq(projection.next_change(Some(99)).unwrap().change_index, 0)
+
+ assert_eq(projection.previous_change(None).unwrap().change_index, 2)
+ assert_eq(projection.previous_change(Some(2)).unwrap().change_index, 1)
+ assert_eq(projection.previous_change(Some(1)).unwrap().change_index, 0)
+ assert_eq(projection.previous_change(Some(0)).unwrap().change_index, 2)
+ assert_eq(projection.previous_change(Some(-1)).unwrap().change_index, 2)
+}
+
+///|
+test "empty projection has no feature output or navigation target" {
+ let projection = DiffFeatureProjection(
+ { changes: [], additional_alignments: [] },
+ TextSnapshot(""),
+ TextSnapshot(""),
+ )
+ assert_eq(projection.change_count(), 0)
+ assert_true(projection.original_decorations().whole_line_ranges().is_empty())
+ assert_true(projection.modified_decorations().whole_line_ranges().is_empty())
+ assert_true(projection.original_overview_entries().is_empty())
+ assert_true(projection.modified_overview_entries().is_empty())
+ assert_true(projection.alignment_anchors().is_empty())
+ assert_true(projection.next_change(None) is None)
+ assert_true(projection.previous_change(None) is None)
+}
diff --git a/editor/internal/viewer/diff_editor/inline_deleted_lines.mbt b/editor/internal/viewer/diff_editor/inline_deleted_lines.mbt
new file mode 100644
index 000000000..78b9ed1aa
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/inline_deleted_lines.mbt
@@ -0,0 +1,110 @@
+// Inline deleted hunks are ordinary CodeEditorWidget-managed ViewZones whose
+// content is rendered by the kernel's native detached-line pipeline. This
+// layer only projects canonical DocumentDiff changes into zone placement and
+// UTF-16 decoration inputs; it never tokenizes or measures text itself.
+
+///|
+fn inline_original_decorations(
+ change : @diff.DetailedLineRangeMapping,
+) -> Array[@code_widget.CodeEditorRenderLinesDecoration] {
+ let decorations : Array[@code_widget.CodeEditorRenderLinesDecoration] = []
+ match change.inner_changes {
+ Some(inner_changes) =>
+ for inner in inner_changes {
+ if !inner.original.is_empty() {
+ decorations.push({
+ range: inner.original,
+ class_name: "diff-editor-inline-char-delete",
+ })
+ }
+ }
+ None => ()
+ }
+ decorations
+}
+
+///|
+fn inline_deleted_zone_anchor(change : @diff.DetailedLineRangeMapping) -> Int {
+ (change.modified.start_line_number - 1).max(0)
+}
+
+///|
+priv struct InlineDeletedViewZones {
+ specs : Array[@code_widget.ManagedCodeEditorViewZoneSpec]
+ render_context : @code_widget.VirtualizedCodeEditorRenderLinesContext?
+ virtualized_renderers : Array[InlineDeletedRenderer]
+}
+
+///|
+pub struct InlineDeletedRenderer {
+ change_index : Int
+ renderer : @code_widget.VirtualizedCodeEditorRenderedLines
+}
+
+///|
+fn configure_inline_deleted_roots(
+ dom_node : @rdom.Element,
+ margin_dom_node : @rdom.Element,
+ change : @diff.DetailedLineRangeMapping,
+) -> Unit {
+ dom_node.set_attribute("class", "view-lines diff-editor-inline-deleted-block")
+ dom_node.set_attribute(
+ "data-original-line-start",
+ change.original.start_line_number.to_string(),
+ )
+ dom_node.set_attribute(
+ "data-original-line-end-exclusive",
+ change.original.end_line_number_exclusive.to_string(),
+ )
+ margin_dom_node.set_attribute("class", "diff-editor-inline-deleted-margin")
+}
+
+///|
+fn inline_deleted_view_zone_specs(
+ renderer : @code_widget.CodeEditorWidget,
+ original_model : @model.TextModel,
+ projection : DiffFeatureProjection,
+ generation : Int,
+) -> InlineDeletedViewZones {
+ let specs : Array[@code_widget.ManagedCodeEditorViewZoneSpec] = []
+ let virtualized_renderers : Array[InlineDeletedRenderer] = []
+ let mut render_context : @code_widget.VirtualizedCodeEditorRenderLinesContext? = None
+ for change_index in 0.. {
+ configure_inline_deleted_roots(
+ rendered.dom_node(),
+ rendered.margin_dom_node(),
+ change,
+ )
+ specs.push(
+ rendered.managed_view_zone_spec(
+ inline_deleted_zone_anchor(change),
+ ordinal=change_index,
+ suppress_mouse_down=false,
+ ),
+ )
+ virtualized_renderers.push({ change_index, renderer: rendered })
+ }
+ None => ()
+ }
+ }
+ { specs, render_context, virtualized_renderers }
+}
diff --git a/editor/internal/viewer/diff_editor/inline_deleted_lines_wbtest.mbt b/editor/internal/viewer/diff_editor/inline_deleted_lines_wbtest.mbt
new file mode 100644
index 000000000..9fc1b873d
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/inline_deleted_lines_wbtest.mbt
@@ -0,0 +1,15 @@
+///|
+test "inline deleted zones use the modified insertion anchor" {
+ let leading = @diff.DetailedLineRangeMapping(
+ LineRange(1, 4),
+ LineRange(1, 1),
+ None,
+ )
+ let middle = @diff.DetailedLineRangeMapping(
+ LineRange(80, 210),
+ LineRange(25, 25),
+ None,
+ )
+ assert_eq(inline_deleted_zone_anchor(leading), 0)
+ assert_eq(inline_deleted_zone_anchor(middle), 24)
+}
diff --git a/editor/internal/viewer/diff_editor/layout_state.mbt b/editor/internal/viewer/diff_editor/layout_state.mbt
new file mode 100644
index 000000000..8cfa49e2f
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/layout_state.mbt
@@ -0,0 +1,97 @@
+///|
+/// DOM-independent layout policy shared by the widget and deterministic
+/// boundary tests. The requested layout is never mutated by responsive
+/// fallback, so a pane that grows from 900px to 901px returns to side-by-side.
+pub struct DiffEditorLayoutState {
+ mut options : DiffEditorOptions
+ mut width : Double
+ mut actual_layout : DiffEditorLayout
+ mut split_ratio : Double
+}
+
+///|
+fn DiffEditorLayoutState::new(
+ options : DiffEditorOptions,
+) -> DiffEditorLayoutState {
+ let state : DiffEditorLayoutState = {
+ options,
+ width: 0.0,
+ actual_layout: options.layout,
+ split_ratio: options.split_ratio,
+ }
+ state
+}
+
+///|
+fn DiffEditorLayoutState::recompute_actual_layout(
+ self : DiffEditorLayoutState,
+) -> Unit {
+ self.actual_layout = match self.options.layout {
+ Inline => Inline
+ SideBySide =>
+ if self.options.automatic_inline &&
+ self.width <= self.options.automatic_inline_width {
+ Inline
+ } else {
+ SideBySide
+ }
+ }
+}
+
+///|
+fn DiffEditorLayoutState::layout(
+ self : DiffEditorLayoutState,
+ width : Double,
+) -> Bool {
+ let previous = self.actual_layout
+ self.width = width.max(0.0)
+ self.recompute_actual_layout()
+ previous != self.actual_layout
+}
+
+///|
+fn DiffEditorLayoutState::update_options(
+ self : DiffEditorLayoutState,
+ options : DiffEditorOptions,
+ recompute_layout? : Bool = true,
+) -> Bool {
+ let previous = self.actual_layout
+ let configured_split_changed = options.split_ratio != self.options.split_ratio
+ self.options = options
+ if configured_split_changed {
+ self.split_ratio = options.split_ratio
+ }
+ if recompute_layout {
+ self.recompute_actual_layout()
+ }
+ previous != self.actual_layout
+}
+
+///|
+fn DiffEditorLayoutState::set_split_from_pointer(
+ self : DiffEditorLayoutState,
+ offset : Double,
+) -> Bool {
+ if !self.options.sash_enabled || self.width <= 0.0 {
+ return false
+ }
+ let next = (offset / self.width).clamp(min=0.1, max=0.9)
+ if next == self.split_ratio {
+ return false
+ }
+ self.split_ratio = next
+ true
+}
+
+///|
+pub fn DiffEditorLayoutState::pane_widths(
+ self : DiffEditorLayoutState,
+) -> (Double, Double) {
+ match self.actual_layout {
+ Inline => (0.0, self.width)
+ SideBySide => {
+ let original = self.width * self.split_ratio
+ (original, self.width - original)
+ }
+ }
+}
diff --git a/editor/internal/viewer/diff_editor/layout_state_wbtest.mbt b/editor/internal/viewer/diff_editor/layout_state_wbtest.mbt
new file mode 100644
index 000000000..01311839b
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/layout_state_wbtest.mbt
@@ -0,0 +1,61 @@
+///|
+test "responsive layout switches exactly at 900/901 without losing preference" {
+ let state = DiffEditorLayoutState::new(DiffEditorOptions())
+ state.layout(900.0) |> ignore
+ assert_true(state.actual_layout == Inline)
+ state.layout(901.0) |> ignore
+ assert_true(state.actual_layout == SideBySide)
+ state.layout(900.0) |> ignore
+ assert_true(state.actual_layout == Inline)
+ state.layout(901.0) |> ignore
+ assert_true(state.actual_layout == SideBySide)
+}
+
+///|
+test "explicit Inline is stable above the responsive boundary" {
+ let state = DiffEditorLayoutState::new(DiffEditorOptions(layout=Inline))
+ state.layout(2000.0) |> ignore
+ assert_true(state.actual_layout == Inline)
+ assert_true(state.pane_widths() == (0.0, 2000.0))
+}
+
+///|
+test "sash ratio is clamped to ten through ninety percent" {
+ let state = DiffEditorLayoutState::new(DiffEditorOptions())
+ state.layout(1000.0) |> ignore
+ assert_true(state.pane_widths() == (500.0, 500.0))
+ assert_true(state.set_split_from_pointer(-100.0))
+ assert_true(state.pane_widths() == (100.0, 900.0))
+ assert_true(state.set_split_from_pointer(2000.0))
+ assert_true(state.pane_widths() == (900.0, 100.0))
+}
+
+///|
+test "disabled sash ignores pointer updates" {
+ let state = DiffEditorLayoutState::new(
+ DiffEditorOptions(sash_enabled=false, split_ratio=0.4),
+ )
+ state.layout(1000.0) |> ignore
+ assert_false(state.set_split_from_pointer(750.0))
+ assert_true(state.pane_widths() == (400.0, 600.0))
+}
+
+///|
+test "interactive sash ratio survives unrelated option and layout updates" {
+ let state = DiffEditorLayoutState::new(DiffEditorOptions())
+ state.layout(1000.0) |> ignore
+ assert_true(state.set_split_from_pointer(700.0))
+ state.update_options(DiffEditorOptions(overview_ruler=false)) |> ignore
+ assert_true(state.pane_widths() == (700.0, 300.0))
+ state.update_options(DiffEditorOptions(layout=Inline, overview_ruler=false))
+ |> ignore
+ state.update_options(
+ DiffEditorOptions(layout=SideBySide, overview_ruler=false),
+ )
+ |> ignore
+ assert_true(state.pane_widths() == (700.0, 300.0))
+
+ state.update_options(DiffEditorOptions(split_ratio=0.6, overview_ruler=false))
+ |> ignore
+ assert_true(state.pane_widths() == (600.0, 400.0))
+}
diff --git a/editor/internal/viewer/diff_editor/lifecycle_diagnostics.mbt b/editor/internal/viewer/diff_editor/lifecycle_diagnostics.mbt
new file mode 100644
index 000000000..86878703d
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/lifecycle_diagnostics.mbt
@@ -0,0 +1,87 @@
+///|
+/// Internal runtime evidence for the composite widget's construction and
+/// teardown invariants. The public editor facade deliberately does not forward
+/// this diagnostic.
+pub(all) struct DiffEditorWidgetLifecycleSnapshot {
+ kernel_count : Int
+ original_kernel_id : String
+ modified_kernel_id : String
+ kernels_are_distinct : Bool
+ view_model_count : Int
+ widget_disposed : Bool
+ dispose_pass_count : Int
+ original_kernel_disposed : Bool
+ modified_kernel_disposed : Bool
+ view_model_disposed : Bool
+ model_pair_is_committing : Bool
+ committed_model_generation : Int
+ overview_ruler_present : Bool
+ overview_ruler_disposed : Bool
+ inline_render_context_present : Bool
+ inline_render_context_disposed : Bool
+ inline_render_live_handle_count : Int
+ inline_render_registered_zone_count : Int
+ resize_observer_present : Bool
+ resize_observer_disposed : Bool
+ after_render_waiter_count : Int
+ after_render_remaining : Int
+ view_model_pending_scheduler_count : Int
+ lifetime_subscription_count : Int
+}
+
+///|
+pub fn DiffEditorWidget::lifecycle_snapshot(
+ self : DiffEditorWidget,
+) -> DiffEditorWidgetLifecycleSnapshot {
+ let original_kernel_id = self.editors.original.get_id()
+ let modified_kernel_id = self.editors.modified.get_id()
+ let overview_ruler_disposed = match self.overview_ruler {
+ Some(ruler) => ruler.is_disposed()
+ None => self.overview_ruler_disposed
+ }
+ let (
+ inline_render_context_disposed,
+ inline_render_live_handle_count,
+ inline_render_registered_zone_count,
+ ) = match self.inline_deleted_render_context {
+ Some(context) =>
+ (
+ context.is_disposed(),
+ context.live_handle_count(),
+ context.registered_zone_count(),
+ )
+ None => (self.inline_render_context_disposed, 0, 0)
+ }
+ let view_model_pending_scheduler_count = (if self.view_model.immediate.is_scheduled() {
+ 1
+ } else {
+ 0
+ }) +
+ (if self.view_model.content_debounce.is_scheduled() { 1 } else { 0 })
+ {
+ kernel_count: 2,
+ original_kernel_id,
+ modified_kernel_id,
+ kernels_are_distinct: original_kernel_id != modified_kernel_id,
+ view_model_count: 1,
+ widget_disposed: self.disposed,
+ dispose_pass_count: self.dispose_pass_count,
+ original_kernel_disposed: self.editors.original.is_disposed(),
+ modified_kernel_disposed: self.editors.modified.is_disposed(),
+ view_model_disposed: self.view_model.disposed,
+ model_pair_is_committing: self.editors.is_committing_model_pair(),
+ committed_model_generation: self.editors.committed_model_generation(),
+ overview_ruler_present: self.overview_ruler is Some(_),
+ overview_ruler_disposed,
+ inline_render_context_present: self.inline_deleted_render_context is Some(_),
+ inline_render_context_disposed,
+ inline_render_live_handle_count,
+ inline_render_registered_zone_count,
+ resize_observer_present: self.resize_observer is Some(_),
+ resize_observer_disposed: self.resize_observer_disposed,
+ after_render_waiter_count: self.layout_render_waiters.length(),
+ after_render_remaining: self.layout_render_remaining,
+ view_model_pending_scheduler_count,
+ lifetime_subscription_count: self.lifetime.length(),
+ }
+}
diff --git a/editor/internal/viewer/diff_editor/moon.pkg b/editor/internal/viewer/diff_editor/moon.pkg
new file mode 100644
index 000000000..3cd513806
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/moon.pkg
@@ -0,0 +1,10 @@
+import {
+ "moonbitlang/editor/base/browser" @base_browser,
+ "moonbitlang/editor/base/common" @base_common,
+ "moonbitlang/editor/internal/viewer/code_editor_widget" @code_widget,
+ "moonbitlang/editor/viewer/common/diff",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbit-community/rabbita/dom" @rdom,
+}
+
+supported_targets = "js"
diff --git a/editor/internal/viewer/diff_editor/options.mbt b/editor/internal/viewer/diff_editor/options.mbt
new file mode 100644
index 000000000..3ad56ed82
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/options.mbt
@@ -0,0 +1,70 @@
+///|
+/// The concrete presentation used by a diff editor. `SideBySide` renders one
+/// code editor per pane; `Inline` keeps the modified editor as the primary
+/// surface and projects original-only content into managed view zones.
+pub(all) enum DiffEditorLayout {
+ SideBySide
+ Inline
+} derive(Eq, Debug)
+
+///|
+/// Diff panes normally inherit the host's code-editor wrapping policy. `Off`
+/// is retained for hosts that require horizontally stable comparison rows.
+pub(all) enum DiffWordWrap {
+ Inherit
+ Off
+} derive(Eq, Debug)
+
+///|
+/// Diff-level options. Code-editor presentation details remain owned by the
+/// two child widgets; these values only control the comparison coordinator.
+pub(all) struct DiffEditorOptions {
+ layout : DiffEditorLayout
+ automatic_inline : Bool
+ automatic_inline_width : Double
+ sash_enabled : Bool
+ split_ratio : Double
+ diff_word_wrap : DiffWordWrap
+ overview_ruler : Bool
+ ignore_trim_whitespace : Bool
+}
+
+///|
+pub fn DiffEditorOptions::DiffEditorOptions(
+ layout? : DiffEditorLayout = SideBySide,
+ automatic_inline? : Bool = true,
+ automatic_inline_width? : Double = 900.0,
+ sash_enabled? : Bool = true,
+ split_ratio? : Double = 0.5,
+ diff_word_wrap? : DiffWordWrap = Inherit,
+ overview_ruler? : Bool = true,
+ ignore_trim_whitespace? : Bool = true,
+) -> DiffEditorOptions {
+ {
+ layout,
+ automatic_inline,
+ automatic_inline_width: automatic_inline_width.max(0.0),
+ sash_enabled,
+ split_ratio: split_ratio.clamp(min=0.1, max=0.9),
+ diff_word_wrap,
+ overview_ruler,
+ ignore_trim_whitespace,
+ }
+}
+
+///|
+pub fn DiffEditorOptions::default() -> DiffEditorOptions {
+ DiffEditorOptions()
+}
+
+///|
+pub extend DiffEditorLayout with Eq::{equal, not_equal}
+
+///|
+pub extend DiffEditorLayout with Debug::{to_repr}
+
+///|
+pub extend DiffWordWrap with Eq::{equal, not_equal}
+
+///|
+pub extend DiffWordWrap with Debug::{to_repr}
diff --git a/editor/internal/viewer/diff_editor/overview_projection.mbt b/editor/internal/viewer/diff_editor/overview_projection.mbt
new file mode 100644
index 000000000..4f7bd7598
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/overview_projection.mbt
@@ -0,0 +1,54 @@
+// Diff-specific projection into the generic CodeEditorWidget overview-ruler
+// contract. Every range is expressed in modified-model coordinates because
+// the managed ruler and pointer navigation both belong to that kernel.
+
+///|
+let diff_overview_removed_color = "var(--vscode-diffEditorOverview-removedForeground, var(--vscode-diffEditor-removedLineBackground, rgba(255, 0, 0, 0.6)))"
+
+///|
+let diff_overview_inserted_color = "var(--vscode-diffEditorOverview-insertedForeground, var(--vscode-diffEditor-insertedLineBackground, rgba(155, 185, 85, 0.6)))"
+
+///|
+fn modified_overview_range(
+ change : @diff.DetailedLineRangeMapping,
+ modified_line_count : Int,
+) -> @base_common.LineRange {
+ if !change.modified.is_empty() {
+ return change.modified
+ }
+ // Deletion-only changes have no physical modified line. Give their removed
+ // lane a one-line anchor in the modified document so the generic ruler can
+ // use the same geometry and reveal API as every other entry.
+ let line_count = modified_line_count.max(1)
+ let anchor = change.modified.start_line_number.clamp(min=1, max=line_count)
+ LineRange(anchor, anchor + 1)
+}
+
+///|
+fn diff_overview_ruler_entries(
+ projection : DiffFeatureProjection,
+ modified_line_count : Int,
+) -> Array[@code_widget.CodeEditorOverviewRulerEntry] {
+ let entries : Array[@code_widget.CodeEditorOverviewRulerEntry] = []
+ for entry in projection.original_overview_entries() {
+ match projection.change_at(entry.change_index) {
+ Some(change) =>
+ entries.push({
+ id: entry.change_index,
+ range: modified_overview_range(change, modified_line_count),
+ color: diff_overview_removed_color,
+ lane: Left,
+ })
+ None => ()
+ }
+ }
+ for entry in projection.modified_overview_entries() {
+ entries.push({
+ id: entry.change_index,
+ range: entry.range,
+ color: diff_overview_inserted_color,
+ lane: Right,
+ })
+ }
+ entries
+}
diff --git a/editor/internal/viewer/diff_editor/overview_projection_wbtest.mbt b/editor/internal/viewer/diff_editor/overview_projection_wbtest.mbt
new file mode 100644
index 000000000..b32e7f50b
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/overview_projection_wbtest.mbt
@@ -0,0 +1,51 @@
+///|
+test "overview projection uses modified geometry and excludes alignments" {
+ let insert = projection_change(1, 1, 1, 2)
+ let modify = projection_change(2, 3, 2, 3)
+ let delete = projection_change(4, 5, 4, 4)
+ let projection = DiffFeatureProjection(
+ {
+ changes: [delete, insert, modify],
+ additional_alignments: [
+ LineRangeMapping(LineRange(3, 3), LineRange(3, 3)),
+ ],
+ },
+ TextSnapshot("old\nchanged\nkeep\ndeleted"),
+ TextSnapshot("new\nchanged\nkeep\ntail"),
+ )
+ let entries = diff_overview_ruler_entries(projection, 4)
+ assert_eq(entries, [
+ {
+ id: 1,
+ range: LineRange(2, 3),
+ color: diff_overview_removed_color,
+ lane: Left,
+ },
+ {
+ id: 2,
+ range: LineRange(4, 5),
+ color: diff_overview_removed_color,
+ lane: Left,
+ },
+ {
+ id: 0,
+ range: LineRange(1, 2),
+ color: diff_overview_inserted_color,
+ lane: Right,
+ },
+ {
+ id: 1,
+ range: LineRange(2, 3),
+ color: diff_overview_inserted_color,
+ lane: Right,
+ },
+ ])
+}
+
+///|
+test "deletion-only overview anchors clamp to the modified model" {
+ let before_start = projection_change(1, 2, 0, 0)
+ let after_end = projection_change(8, 9, 9, 9)
+ assert_eq(modified_overview_range(before_start, 4), LineRange(1, 2))
+ assert_eq(modified_overview_range(after_end, 4), LineRange(4, 5))
+}
diff --git a/editor/internal/viewer/diff_editor/pkg.generated.mbti b/editor/internal/viewer/diff_editor/pkg.generated.mbti
new file mode 100644
index 000000000..9f4da924c
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/pkg.generated.mbti
@@ -0,0 +1,287 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/editor/internal/viewer/diff_editor"
+
+import {
+ "moonbit-community/rabbita/dom",
+ "moonbitlang/core/debug",
+ "moonbitlang/editor/base/browser",
+ "moonbitlang/editor/base/common",
+ "moonbitlang/editor/internal/viewer/code_editor_widget",
+ "moonbitlang/editor/viewer/common/diff",
+ "moonbitlang/editor/viewer/common/model",
+}
+
+// Values
+pub fn compute_managed_alignment_spacers(DiffFeatureProjection, PaneGeometrySnapshot, PaneGeometrySnapshot, inner_hunk_alignment? : Bool) -> ManagedAlignmentSpacers
+
+// Errors
+
+// Types and methods
+pub struct DiffEditorEditors {
+ original : @code_editor_widget.CodeEditorWidget
+ modified : @code_editor_widget.CodeEditorWidget
+ owned_services : @code_editor_widget.CodeEditorServices?
+ model_pair_commit : DiffEditorModelPairCommitBarrier
+ mut disposed : Bool
+}
+
+pub(all) enum DiffEditorLayout {
+ SideBySide
+ Inline
+} derive(Eq, @debug.Debug)
+pub fn DiffEditorLayout::equal(Self, Self) -> Bool
+pub fn DiffEditorLayout::not_equal(Self, Self) -> Bool
+pub fn DiffEditorLayout::to_repr(Self) -> @debug.Repr
+
+pub struct DiffEditorLayoutState {
+ mut options : DiffEditorOptions
+ mut width : Double
+ mut actual_layout : DiffEditorLayout
+ mut split_ratio : Double
+}
+pub fn DiffEditorLayoutState::pane_widths(Self) -> (Double, Double)
+
+pub(all) struct DiffEditorModelData {
+ original : @model.TextModel
+ modified : @model.TextModel
+}
+
+type DiffEditorModelPairCommitBarrier
+
+pub(all) struct DiffEditorOptions {
+ layout : DiffEditorLayout
+ automatic_inline : Bool
+ automatic_inline_width : Double
+ sash_enabled : Bool
+ split_ratio : Double
+ diff_word_wrap : DiffWordWrap
+ overview_ruler : Bool
+ ignore_trim_whitespace : Bool
+}
+pub fn DiffEditorOptions::DiffEditorOptions(layout? : DiffEditorLayout, automatic_inline? : Bool, automatic_inline_width? : Double, sash_enabled? : Bool, split_ratio? : Double, diff_word_wrap? : DiffWordWrap, overview_ruler? : Bool, ignore_trim_whitespace? : Bool) -> Self
+pub fn DiffEditorOptions::default() -> Self
+
+pub struct DiffEditorViewModel {
+ mut provider : &@diff.DocumentDiffProvider
+ mut provider_subscription : @common.Disposable?
+ mut model : DiffEditorModelData?
+ model_subscriptions : Array[@common.Disposable]
+ mut options : @diff.DocumentDiffOptions
+ mut state : DiffEditorViewModelState
+ mut current_diff : @diff.DocumentDiff?
+ mut generation : Int
+ immediate : @browser.RunOnceScheduler
+ content_debounce : @browser.RunOnceScheduler
+ did_change_model : @common.Emitter[Unit]
+ did_update : @common.Emitter[Unit]
+ mut disposed : Bool
+}
+pub fn DiffEditorViewModel::DiffEditorViewModel(&@diff.DocumentDiffProvider, options? : @diff.DocumentDiffOptions, schedule_timeout? : (() -> Unit, Int) -> @common.Disposable) -> Self
+pub fn DiffEditorViewModel::dispose(Self) -> Unit
+pub fn DiffEditorViewModel::get_diff(Self) -> @diff.DocumentDiff?
+pub fn DiffEditorViewModel::get_model(Self) -> DiffEditorModelData?
+pub fn DiffEditorViewModel::get_state(Self) -> DiffEditorViewModelState
+pub fn DiffEditorViewModel::is_diff_up_to_date(Self) -> Bool
+pub fn DiffEditorViewModel::on_did_update_diff(Self, () -> Unit) -> @common.Disposable
+pub fn DiffEditorViewModel::set_model(Self, DiffEditorModelData?) -> DiffEditorModelData?
+pub fn DiffEditorViewModel::set_provider(Self, &@diff.DocumentDiffProvider) -> Unit
+pub fn DiffEditorViewModel::update_options(Self, @diff.DocumentDiffOptions) -> Unit
+
+pub(all) enum DiffEditorViewModelState {
+ NoModel
+ Pending
+ Ready
+ Failed
+} derive(Eq, @debug.Debug)
+pub fn DiffEditorViewModelState::equal(Self, Self) -> Bool
+pub fn DiffEditorViewModelState::not_equal(Self, Self) -> Bool
+pub fn DiffEditorViewModelState::to_repr(Self) -> @debug.Repr
+
+pub struct DiffEditorWidget {
+ host : @dom.Element
+ root : @dom.Element
+ panes : @dom.Element
+ original_host : @dom.Element
+ modified_host : @dom.Element
+ sash : @dom.Element
+ live_region : @dom.Element
+ editors : DiffEditorEditors
+ view_model : DiffEditorViewModel
+ mut editor_options : @code_editor_widget.CodeEditorOptions
+ mut options : DiffEditorOptions
+ layout_state : DiffEditorLayoutState
+ pointer_monitor : @browser.GlobalPointerMoveMonitor
+ lifetime : Array[@common.Disposable]
+ mut resize_observer : @common.Disposable?
+ mut overview_ruler : @code_editor_widget.CodeEditorOverviewRuler?
+ mut original_decoration_ids : Array[String]
+ mut modified_decoration_ids : Array[String]
+ mut original_alignment_zone_ids : Array[String]
+ mut modified_alignment_zone_ids : Array[String]
+ mut inline_deleted_render_context : @code_editor_widget.VirtualizedCodeEditorRenderLinesContext?
+ mut inline_deleted_renderers : Array[InlineDeletedRenderer]
+ mut original_alignment_geometry : PaneGeometrySnapshot?
+ mut modified_alignment_geometry : PaneGeometrySnapshot?
+ mut projection : DiffFeatureProjection?
+ mut navigation_index : Int?
+ mut reconciling_generation : Int
+ mut is_reconciling : Bool
+ layout_render_waiters : Array[@common.Disposable]
+ mut layout_render_generation : Int
+ mut layout_render_remaining : Int
+ mut has_valid_layout_box : Bool
+ mut is_syncing_scroll : Bool
+ mut dispose_pass_count : Int
+ mut resize_observer_disposed : Bool
+ mut overview_ruler_disposed : Bool
+ mut inline_render_context_disposed : Bool
+ mut disposed : Bool
+}
+pub fn DiffEditorWidget::create(@dom.Element, services? : @code_editor_widget.CodeEditorServices, editor_options? : @code_editor_widget.CodeEditorOptions, options? : DiffEditorOptions, &@diff.DocumentDiffProvider) -> Self
+pub fn DiffEditorWidget::dispose(Self) -> Unit
+pub fn DiffEditorWidget::focus(Self) -> Unit
+pub fn DiffEditorWidget::get_diff(Self) -> @diff.DocumentDiff?
+pub fn DiffEditorWidget::get_layout(Self) -> DiffEditorLayout
+pub fn DiffEditorWidget::get_model(Self) -> DiffEditorModelData?
+pub fn DiffEditorWidget::is_diff_up_to_date(Self) -> Bool
+pub fn DiffEditorWidget::layout(Self) -> Unit
+pub fn DiffEditorWidget::lifecycle_snapshot(Self) -> DiffEditorWidgetLifecycleSnapshot
+pub fn DiffEditorWidget::on_did_update_diff(Self, () -> Unit) -> @common.Disposable
+pub fn DiffEditorWidget::reveal_next_change(Self) -> Bool
+pub fn DiffEditorWidget::reveal_previous_change(Self) -> Bool
+pub fn DiffEditorWidget::set_diff_provider(Self, &@diff.DocumentDiffProvider) -> Unit
+pub fn DiffEditorWidget::set_model(Self, DiffEditorModelData?) -> DiffEditorModelData?
+pub fn DiffEditorWidget::update_options(Self, @code_editor_widget.CodeEditorOptions, DiffEditorOptions) -> Unit
+
+pub(all) struct DiffEditorWidgetLifecycleSnapshot {
+ kernel_count : Int
+ original_kernel_id : String
+ modified_kernel_id : String
+ kernels_are_distinct : Bool
+ view_model_count : Int
+ widget_disposed : Bool
+ dispose_pass_count : Int
+ original_kernel_disposed : Bool
+ modified_kernel_disposed : Bool
+ view_model_disposed : Bool
+ model_pair_is_committing : Bool
+ committed_model_generation : Int
+ overview_ruler_present : Bool
+ overview_ruler_disposed : Bool
+ inline_render_context_present : Bool
+ inline_render_context_disposed : Bool
+ inline_render_live_handle_count : Int
+ inline_render_registered_zone_count : Int
+ resize_observer_present : Bool
+ resize_observer_disposed : Bool
+ after_render_waiter_count : Int
+ after_render_remaining : Int
+ view_model_pending_scheduler_count : Int
+ lifetime_subscription_count : Int
+}
+
+pub struct DiffFeatureProjection {
+ sorted_changes : Array[@diff.DetailedLineRangeMapping]
+ original_decorations : DiffPaneDecorations
+ modified_decorations : DiffPaneDecorations
+ original_overview : Array[DiffOverviewEntry]
+ modified_overview : Array[DiffOverviewEntry]
+ alignment_anchors : Array[@diff.LineRangeMapping]
+}
+pub fn DiffFeatureProjection::DiffFeatureProjection(@diff.DocumentDiff, @model.TextSnapshot, @model.TextSnapshot) -> Self
+pub fn DiffFeatureProjection::alignment_anchors(Self) -> Array[@diff.LineRangeMapping]
+pub fn DiffFeatureProjection::change_at(Self, Int) -> @diff.DetailedLineRangeMapping?
+pub fn DiffFeatureProjection::change_count(Self) -> Int
+pub fn DiffFeatureProjection::modified_decorations(Self) -> DiffPaneDecorations
+pub fn DiffFeatureProjection::modified_overview_entries(Self) -> Array[DiffOverviewEntry]
+pub fn DiffFeatureProjection::next_change(Self, Int?) -> DiffNavigationTarget?
+pub fn DiffFeatureProjection::original_decorations(Self) -> DiffPaneDecorations
+pub fn DiffFeatureProjection::original_overview_entries(Self) -> Array[DiffOverviewEntry]
+pub fn DiffFeatureProjection::previous_change(Self, Int?) -> DiffNavigationTarget?
+
+pub(all) struct DiffNavigationTarget {
+ change_index : Int
+ change_count : Int
+ change : @diff.DetailedLineRangeMapping
+} derive(Eq, @debug.Debug)
+pub fn DiffNavigationTarget::equal(Self, Self) -> Bool
+pub fn DiffNavigationTarget::not_equal(Self, Self) -> Bool
+pub fn DiffNavigationTarget::to_repr(Self) -> @debug.Repr
+
+pub(all) struct DiffOverviewEntry {
+ change_index : Int
+ range : @common.LineRange
+} derive(Eq, @debug.Debug)
+pub fn DiffOverviewEntry::equal(Self, Self) -> Bool
+pub fn DiffOverviewEntry::not_equal(Self, Self) -> Bool
+pub fn DiffOverviewEntry::to_repr(Self) -> @debug.Repr
+
+pub struct DiffPaneDecorations {
+ whole_line : Array[@common.Range]
+ inner : Array[@common.Range]
+}
+pub fn DiffPaneDecorations::inner_ranges(Self) -> Array[@common.Range]
+pub fn DiffPaneDecorations::whole_line_ranges(Self) -> Array[@common.Range]
+
+pub(all) enum DiffWordWrap {
+ Inherit
+ Off
+} derive(Eq, @debug.Debug)
+pub fn DiffWordWrap::equal(Self, Self) -> Bool
+pub fn DiffWordWrap::not_equal(Self, Self) -> Bool
+pub fn DiffWordWrap::to_repr(Self) -> @debug.Repr
+
+pub struct InlineDeletedRenderer {
+ change_index : Int
+ renderer : @code_editor_widget.VirtualizedCodeEditorRenderedLines
+}
+
+pub(all) struct ManagedAlignmentSpacer {
+ after_line_number : Int
+ height_in_px : Double
+} derive(Eq, @debug.Debug)
+pub fn ManagedAlignmentSpacer::equal(Self, Self) -> Bool
+pub fn ManagedAlignmentSpacer::not_equal(Self, Self) -> Bool
+pub fn ManagedAlignmentSpacer::to_repr(Self) -> @debug.Repr
+
+pub struct ManagedAlignmentSpacers {
+ original : Array[ManagedAlignmentSpacer]
+ modified : Array[ManagedAlignmentSpacer]
+}
+pub fn ManagedAlignmentSpacers::modified(Self) -> Array[ManagedAlignmentSpacer]
+pub fn ManagedAlignmentSpacers::original(Self) -> Array[ManagedAlignmentSpacer]
+
+pub(all) struct PaneForeignZoneHeight {
+ after_line_number : Int
+ height_in_px : Double
+} derive(Eq, @debug.Debug)
+pub fn PaneForeignZoneHeight::equal(Self, Self) -> Bool
+pub fn PaneForeignZoneHeight::not_equal(Self, Self) -> Bool
+pub fn PaneForeignZoneHeight::to_repr(Self) -> @debug.Repr
+
+pub struct PaneGeometrySnapshot {
+ line_height : Double
+ viewport_width : Double
+ line_heights : Array[Double]
+ line_max_columns : Array[Int]?
+ foreign_zone_heights : Array[PaneForeignZoneHeight]
+ cumulative_pixels : Array[Double]
+}
+pub fn PaneGeometrySnapshot::PaneGeometrySnapshot(Int, Double, model_line_heights? : Array[PaneModelLineHeight], hidden_lines? : Array[@common.LineRange], foreign_zone_heights? : Array[PaneForeignZoneHeight], line_max_columns? : Array[Int], viewport_width? : Double) -> Self
+pub fn PaneGeometrySnapshot::content_height(Self) -> Double
+pub fn PaneGeometrySnapshot::foreign_zone_heights(Self) -> Array[PaneForeignZoneHeight]
+pub fn PaneGeometrySnapshot::line_count(Self) -> Int
+pub fn PaneGeometrySnapshot::line_height(Self) -> Double
+pub fn PaneGeometrySnapshot::viewport_width(Self) -> Double
+
+pub(all) struct PaneModelLineHeight {
+ line_number : Int
+ height_in_px : Double
+} derive(Eq, @debug.Debug)
+pub fn PaneModelLineHeight::equal(Self, Self) -> Bool
+pub fn PaneModelLineHeight::not_equal(Self, Self) -> Bool
+pub fn PaneModelLineHeight::to_repr(Self) -> @debug.Repr
+
+// Type aliases
+
+// Traits
diff --git a/editor/internal/viewer/diff_editor/reconcile_transaction.mbt b/editor/internal/viewer/diff_editor/reconcile_transaction.mbt
new file mode 100644
index 000000000..4fae6b24e
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/reconcile_transaction.mbt
@@ -0,0 +1,95 @@
+// The synchronous commit seam for one diff generation. Projection is produced
+// before this seam; every visual feature commits in the same fixed order.
+
+///|
+fn pane_geometry_from_code_editor_parts(
+ line_height : Double,
+ model_line_heights : Array[@code_widget.CodeEditorModelLineHeight],
+ foreign_zone_heights : Array[@code_widget.CodeEditorForeignZoneHeight],
+ line_max_columns : Array[Int],
+ viewport_width : Double,
+) -> PaneGeometrySnapshot {
+ PaneGeometrySnapshot(
+ model_line_heights.length(),
+ line_height,
+ model_line_heights=model_line_heights.map(item => {
+ (
+ { line_number: item.line_number, height_in_px: item.height_in_px } :
+ PaneModelLineHeight)
+ }),
+ foreign_zone_heights=foreign_zone_heights.map(item => {
+ (
+ {
+ after_line_number: item.after_line_number,
+ height_in_px: item.height_in_px,
+ } : PaneForeignZoneHeight)
+ }),
+ line_max_columns~,
+ viewport_width~,
+ )
+}
+
+///|
+fn pane_geometry_from_code_editor(
+ snapshot : @code_widget.CodeEditorGeometrySnapshot,
+) -> PaneGeometrySnapshot {
+ pane_geometry_from_code_editor_parts(
+ snapshot.line_height(),
+ snapshot.model_line_heights(),
+ snapshot.foreign_zone_heights(),
+ snapshot.line_max_columns(),
+ snapshot.scroll_content().viewport_width,
+ )
+}
+
+///|
+fn same_alignment_geometry(
+ left : PaneGeometrySnapshot,
+ right : PaneGeometrySnapshot,
+) -> Bool {
+ left.line_height == right.line_height &&
+ left.viewport_width == right.viewport_width &&
+ left.line_heights == right.line_heights &&
+ left.line_max_columns == right.line_max_columns &&
+ left.foreign_zone_heights == right.foreign_zone_heights &&
+ left.cumulative_pixels == right.cumulative_pixels
+}
+
+///|
+fn same_optional_alignment_geometry(
+ left : PaneGeometrySnapshot?,
+ right : PaneGeometrySnapshot?,
+) -> Bool {
+ match (left, right) {
+ (None, None) => true
+ (Some(left), Some(right)) => same_alignment_geometry(left, right)
+ _ => false
+ }
+}
+
+///|
+fn alignment_geometry_pair_changed(
+ original : PaneGeometrySnapshot?,
+ modified : PaneGeometrySnapshot?,
+ previous_original : PaneGeometrySnapshot?,
+ previous_modified : PaneGeometrySnapshot?,
+) -> Bool {
+ !same_optional_alignment_geometry(original, previous_original) ||
+ !same_optional_alignment_geometry(modified, previous_modified)
+}
+
+///|
+fn commit_diff_reconcile_generation(
+ generation : Int,
+ apply_decorations : () -> Unit,
+ apply_original_view_zones : (Int) -> Unit,
+ apply_modified_view_zones : (Int) -> Unit,
+ restore_stable_viewports : () -> Unit,
+ apply_overview_ruler : (Int) -> Unit,
+) -> Unit {
+ apply_decorations()
+ apply_original_view_zones(generation)
+ apply_modified_view_zones(generation)
+ restore_stable_viewports()
+ apply_overview_ruler(generation)
+}
diff --git a/editor/internal/viewer/diff_editor/reconcile_transaction_wbtest.mbt b/editor/internal/viewer/diff_editor/reconcile_transaction_wbtest.mbt
new file mode 100644
index 000000000..04e08f394
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/reconcile_transaction_wbtest.mbt
@@ -0,0 +1,115 @@
+///|
+test "reconcile commit uses one generation and fixed synchronous order" {
+ let events : Array[String] = ["document-diff"]
+ let mut original_generation = -1
+ let mut modified_generation = -1
+ let mut overview_generation = -1
+ commit_diff_reconcile_generation(
+ 17,
+ () => events.push("decorations"),
+ generation => {
+ original_generation = generation
+ events.push("original-view-zones")
+ },
+ generation => {
+ modified_generation = generation
+ events.push("modified-view-zones")
+ },
+ () => events.push("stable-restore"),
+ generation => {
+ overview_generation = generation
+ events.push("overview-ruler")
+ },
+ )
+ assert_eq(original_generation, 17)
+ assert_eq(modified_generation, 17)
+ assert_eq(overview_generation, 17)
+ assert_true(
+ events ==
+ [
+ "document-diff", "decorations", "original-view-zones", "modified-view-zones",
+ "stable-restore", "overview-ruler",
+ ],
+ )
+}
+
+///|
+test "kernel geometry adapter preserves wrap hidden columns and foreign zones" {
+ let geometry = pane_geometry_from_code_editor_parts(
+ 18.0,
+ [
+ { line_number: 1, height_in_px: 18.0 },
+ { line_number: 2, height_in_px: 36.0 },
+ { line_number: 3, height_in_px: 0.0 },
+ ],
+ [{ after_line_number: 1, height_in_px: 7.0 }],
+ [4, 12, 8],
+ 640.0,
+ )
+ assert_eq(geometry.line_height(), 18.0)
+ assert_eq(geometry.line_count(), 3)
+ assert_eq(geometry.content_height(), 61.0)
+ assert_eq(geometry.viewport_width(), 640.0)
+ assert_true(geometry.line_heights == [18.0, 36.0, 0.0])
+ assert_true(geometry.line_max_columns == Some([4, 12, 8]))
+ assert_true(
+ geometry.foreign_zone_heights() ==
+ [{ after_line_number: 1, height_in_px: 7.0 }],
+ )
+}
+
+///|
+test "owned-zone-excluded geometry does not self-trigger another reconcile" {
+ let original = PaneGeometrySnapshot(
+ 3,
+ 18.0,
+ foreign_zone_heights=[{ after_line_number: 2, height_in_px: 9.0 }],
+ line_max_columns=[2, 2, 2],
+ )
+ let same_original = PaneGeometrySnapshot(
+ 3,
+ 18.0,
+ foreign_zone_heights=[{ after_line_number: 2, height_in_px: 9.0 }],
+ line_max_columns=[2, 2, 2],
+ )
+ let modified = PaneGeometrySnapshot(3, 18.0, line_max_columns=[2, 2, 2])
+ assert_false(
+ alignment_geometry_pair_changed(
+ Some(same_original),
+ Some(modified),
+ Some(original),
+ Some(modified),
+ ),
+ )
+
+ // A real foreign ViewZone remains in the kernel snapshot and must cause a
+ // new geometry transaction; managed/owned zone ids were excluded upstream.
+ let with_new_foreign_zone = PaneGeometrySnapshot(
+ 3,
+ 18.0,
+ foreign_zone_heights=[{ after_line_number: 2, height_in_px: 10.0 }],
+ line_max_columns=[2, 2, 2],
+ )
+ assert_true(
+ alignment_geometry_pair_changed(
+ Some(with_new_foreign_zone),
+ Some(modified),
+ Some(original),
+ Some(modified),
+ ),
+ )
+}
+
+///|
+test "reconcile commit has only the synchronous overview lane after restore" {
+ let mut calls = 0
+ commit_diff_reconcile_generation(
+ 3,
+ () => calls += 1,
+ _generation => calls += 1,
+ _generation => calls += 1,
+ () => calls += 1,
+ _generation => calls += 1,
+ )
+ assert_eq(calls, 5)
+}
diff --git a/editor/internal/viewer/diff_editor/view_model.mbt b/editor/internal/viewer/diff_editor/view_model.mbt
new file mode 100644
index 000000000..5269c0396
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/view_model.mbt
@@ -0,0 +1,537 @@
+///|
+/// The two caller-owned models compared by the diff ViewModel. The ViewModel
+/// only retains and observes them; it never disposes either model.
+pub(all) struct DiffEditorModelData {
+ original : @model.TextModel
+ modified : @model.TextModel
+}
+
+///|
+pub(all) enum DiffEditorViewModelState {
+ NoModel
+ Pending
+ Ready
+ Failed
+} derive(Eq, Debug)
+
+///|
+/// DOM-free owner of one model pair and its synchronous provider result.
+/// Browser timing is injected through RunOnceScheduler so white-box tests can
+/// drive the 0/200ms lanes without sleeping.
+pub struct DiffEditorViewModel {
+ mut provider : &@diff.DocumentDiffProvider
+ mut provider_subscription : @base_common.Disposable?
+ mut model : DiffEditorModelData?
+ model_subscriptions : Array[@base_common.Disposable]
+ mut options : @diff.DocumentDiffOptions
+ mut state : DiffEditorViewModelState
+ mut current_diff : @diff.DocumentDiff?
+ mut generation : Int
+ immediate : @base_browser.RunOnceScheduler
+ content_debounce : @base_browser.RunOnceScheduler
+ did_change_model : @base_common.Emitter[Unit]
+ did_update : @base_common.Emitter[Unit]
+ mut disposed : Bool
+}
+
+///|
+pub fn DiffEditorViewModel::DiffEditorViewModel(
+ provider : &@diff.DocumentDiffProvider,
+ options? : @diff.DocumentDiffOptions = { ignore_trim_whitespace: true },
+ schedule_timeout? : (() -> Unit, Int) -> @base_common.Disposable = @base_browser.schedule_timeout,
+) -> DiffEditorViewModel {
+ let self : DiffEditorViewModel = {
+ provider,
+ provider_subscription: None,
+ model: None,
+ model_subscriptions: [],
+ options,
+ state: NoModel,
+ current_diff: None,
+ generation: 0,
+ immediate: @base_browser.RunOnceScheduler::new_with_timer(
+ 0, schedule_timeout,
+ ),
+ content_debounce: @base_browser.RunOnceScheduler::new_with_timer(
+ 200, schedule_timeout,
+ ),
+ did_change_model: Emitter(),
+ did_update: Emitter(),
+ disposed: false,
+ }
+ self.install_provider_subscription()
+ self
+}
+
+///|
+fn DiffEditorViewModel::install_provider_subscription(
+ self : DiffEditorViewModel,
+) -> Unit {
+ match self.provider_subscription {
+ Some(subscription) => subscription.dispose()
+ None => ()
+ }
+ self.provider_subscription = Some(
+ self.provider.on_did_change(() => self.invalidate_immediately()),
+ )
+}
+
+///|
+fn same_model_pair(
+ left : DiffEditorModelData?,
+ right : DiffEditorModelData?,
+) -> Bool {
+ match (left, right) {
+ (None, None) => true
+ (Some(left), Some(right)) =>
+ left.original.same(right.original) && left.modified.same(right.modified)
+ _ => false
+ }
+}
+
+///|
+pub fn DiffEditorViewModel::set_model(
+ self : DiffEditorViewModel,
+ model : DiffEditorModelData?,
+) -> DiffEditorModelData? {
+ let previous = self.model
+ // The return value is an ownership-transfer seam for hosts: every returned
+ // pair is already detached and may be disposed immediately. Returning the
+ // still-installed pair for a no-op would violate that contract.
+ if self.disposed || same_model_pair(previous, model) {
+ return None
+ }
+ self.generation += 1
+ self.immediate.cancel()
+ self.content_debounce.cancel()
+ self.dispose_model_subscriptions()
+ self.model = model
+ self.current_diff = None
+ match model {
+ None => self.state = NoModel
+ Some(model) => {
+ self.state = Pending
+ let original_identity = model.original.identity()
+ let modified_identity = model.modified.identity()
+ self.model_subscriptions.push(
+ model.original.on_did_change_content(_ => {
+ self.invalidate_content_for_model_pair(
+ original_identity, modified_identity,
+ )
+ }),
+ )
+ self.model_subscriptions.push(
+ model.modified.on_did_change_content(_ => {
+ self.invalidate_content_for_model_pair(
+ original_identity, modified_identity,
+ )
+ }),
+ )
+ self.model_subscriptions.push(
+ model.original.on_will_dispose(() => {
+ self.clear_model_pair_on_will_dispose(
+ original_identity, modified_identity,
+ )
+ }),
+ )
+ self.model_subscriptions.push(
+ model.modified.on_will_dispose(() => {
+ self.clear_model_pair_on_will_dispose(
+ original_identity, modified_identity,
+ )
+ }),
+ )
+ self.schedule_immediate_compute()
+ }
+ }
+ // The model event is a synchronous transaction boundary. DiffEditorWidget
+ // mirrors the already-committed pair into both kernels before the ordinary
+ // diff update event can reconcile decorations, zones, and overview state.
+ self.did_change_model.fire(())
+ self.did_update.fire(())
+ previous
+}
+
+///|
+fn DiffEditorViewModel::clear_model_pair_on_will_dispose(
+ self : DiffEditorViewModel,
+ original_identity : Int,
+ modified_identity : Int,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ guard self.model is Some(current) &&
+ current.original.identity() == original_identity &&
+ current.modified.identity() == modified_identity else {
+ // A detached model can finish disposal after a replacement. Its callback
+ // must never clear the newly installed pair.
+ return
+ }
+ self.generation += 1
+ self.immediate.cancel()
+ self.content_debounce.cancel()
+ self.dispose_model_subscriptions()
+ self.model = None
+ self.current_diff = None
+ self.state = NoModel
+ // Models are caller-owned. The disposing member and its still-live sibling
+ // are only detached here; neither is disposed and no ownership transfer is
+ // synthesized for an external lifecycle invalidation.
+ self.did_change_model.fire(())
+ self.did_update.fire(())
+}
+
+///|
+fn DiffEditorViewModel::dispose_model_subscriptions(
+ self : DiffEditorViewModel,
+) -> Unit {
+ for subscription in self.model_subscriptions {
+ subscription.dispose()
+ }
+ self.model_subscriptions.clear()
+}
+
+///|
+pub fn DiffEditorViewModel::set_provider(
+ self : DiffEditorViewModel,
+ provider : &@diff.DocumentDiffProvider,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.provider = provider
+ self.install_provider_subscription()
+ self.invalidate_immediately()
+}
+
+///|
+pub fn DiffEditorViewModel::update_options(
+ self : DiffEditorViewModel,
+ options : @diff.DocumentDiffOptions,
+) -> Unit {
+ if self.disposed || self.options == options {
+ return
+ }
+ self.options = options
+ self.invalidate_immediately()
+}
+
+///|
+fn DiffEditorViewModel::begin_pending(self : DiffEditorViewModel) -> Unit {
+ self.generation += 1
+ self.current_diff = None
+ self.state = if self.model is Some(_) { Pending } else { NoModel }
+ self.did_update.fire(())
+}
+
+///|
+fn DiffEditorViewModel::invalidate_immediately(
+ self : DiffEditorViewModel,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.begin_pending()
+ self.content_debounce.cancel()
+ self.schedule_immediate_compute()
+}
+
+///|
+fn DiffEditorViewModel::invalidate_content(self : DiffEditorViewModel) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.begin_pending()
+ self.immediate.cancel()
+ let generation = self.generation
+ self.content_debounce.schedule(() => self.compute_generation(generation))
+}
+
+///|
+fn DiffEditorViewModel::invalidate_content_for_model_pair(
+ self : DiffEditorViewModel,
+ original_identity : Int,
+ modified_identity : Int,
+) -> Unit {
+ guard self.model is Some(current) &&
+ current.original.identity() == original_identity &&
+ current.modified.identity() == modified_identity else {
+ return
+ }
+ self.invalidate_content()
+}
+
+///|
+fn DiffEditorViewModel::schedule_immediate_compute(
+ self : DiffEditorViewModel,
+) -> Unit {
+ if self.model is Some(_) {
+ let generation = self.generation
+ self.immediate.schedule(() => self.compute_generation(generation))
+ }
+}
+
+///|
+fn DiffEditorViewModel::compute_generation(
+ self : DiffEditorViewModel,
+ generation : Int,
+) -> Unit {
+ if !self.disposed && self.generation == generation {
+ self.compute_current()
+ }
+}
+
+///|
+fn valid_line_range(range : @base_common.LineRange, line_count : Int) -> Bool {
+ range.start_line_number >= 1 &&
+ range.end_line_number_exclusive >= range.start_line_number &&
+ range.end_line_number_exclusive <= line_count + 1
+}
+
+///|
+fn valid_range(
+ range : @base_common.Range,
+ snapshot : @model.TextSnapshot,
+) -> Bool {
+ if range.start_line_number < 1 ||
+ range.end_line_number < range.start_line_number ||
+ range.end_line_number > snapshot.line_count() {
+ return false
+ }
+ let start_max = snapshot.get_line_max_column(range.start_line_number)
+ let end_max = snapshot.get_line_max_column(range.end_line_number)
+ range.start_column >= 1 &&
+ range.start_column <= start_max &&
+ range.end_column >= 1 &&
+ range.end_column <= end_max &&
+ (
+ range.start_line_number < range.end_line_number ||
+ range.start_column <= range.end_column
+ )
+}
+
+///|
+fn position_le(
+ first : @base_common.Position,
+ second : @base_common.Position,
+) -> Bool {
+ first.line_number < second.line_number ||
+ (first.line_number == second.line_number && first.column <= second.column)
+}
+
+///|
+fn line_range_contains_range(
+ parent : @base_common.LineRange,
+ child : @base_common.Range,
+ snapshot : @model.TextSnapshot,
+) -> Bool {
+ if !parent.is_empty() {
+ return child.start_line_number >= parent.start_line_number &&
+ child.end_line_number < parent.end_line_number_exclusive
+ }
+ if !child.is_empty() {
+ return false
+ }
+ let anchor = if parent.start_line_number <= snapshot.line_count() {
+ @base_common.Position(parent.start_line_number.max(1), 1)
+ } else {
+ let last_line_number = snapshot.line_count().max(1)
+ Position(last_line_number, snapshot.get_line_max_column(last_line_number))
+ }
+ child.get_start_position() == anchor && child.get_end_position() == anchor
+}
+
+///|
+fn valid_inner_changes(
+ inner_changes : Array[@diff.RangeMapping],
+ parent_original : @base_common.LineRange,
+ parent_modified : @base_common.LineRange,
+ original : @model.TextSnapshot,
+ modified : @model.TextSnapshot,
+) -> Bool {
+ let mut previous_original_end : @base_common.Position? = None
+ let mut previous_modified_end : @base_common.Position? = None
+ for inner in inner_changes {
+ if !valid_range(inner.original, original) ||
+ !valid_range(inner.modified, modified) ||
+ !line_range_contains_range(parent_original, inner.original, original) ||
+ !line_range_contains_range(parent_modified, inner.modified, modified) {
+ return false
+ }
+ match (previous_original_end, previous_modified_end) {
+ (Some(original_end), Some(modified_end)) =>
+ if !position_le(original_end, inner.original.get_start_position()) ||
+ !position_le(modified_end, inner.modified.get_start_position()) {
+ return false
+ }
+ _ => ()
+ }
+ previous_original_end = Some(inner.original.get_end_position())
+ previous_modified_end = Some(inner.modified.get_end_position())
+ }
+ true
+}
+
+///|
+fn valid_document_diff(
+ result : @diff.DocumentDiff,
+ original : @model.TextSnapshot,
+ modified : @model.TextSnapshot,
+) -> Bool {
+ let mut original_end = 1
+ let mut modified_end = 1
+ for change in result.changes {
+ if !valid_line_range(change.original, original.line_count()) ||
+ !valid_line_range(change.modified, modified.line_count()) ||
+ change.original.start_line_number < original_end ||
+ change.modified.start_line_number < modified_end {
+ return false
+ }
+ original_end = change.original.end_line_number_exclusive
+ modified_end = change.modified.end_line_number_exclusive
+ match change.inner_changes {
+ Some(inner_changes) =>
+ if !valid_inner_changes(
+ inner_changes,
+ change.original,
+ change.modified,
+ original,
+ modified,
+ ) {
+ return false
+ }
+ None => ()
+ }
+ }
+ original_end = 1
+ modified_end = 1
+ for alignment in result.additional_alignments {
+ if !valid_line_range(alignment.original, original.line_count()) ||
+ !valid_line_range(alignment.modified, modified.line_count()) ||
+ alignment.original.start_line_number < original_end ||
+ alignment.modified.start_line_number < modified_end {
+ return false
+ }
+ original_end = alignment.original.end_line_number_exclusive
+ modified_end = alignment.modified.end_line_number_exclusive
+ }
+ true
+}
+
+///|
+fn DiffEditorViewModel::compute_current(self : DiffEditorViewModel) -> Unit {
+ if self.disposed {
+ return
+ }
+ guard self.model is Some(model) else {
+ self.state = NoModel
+ self.current_diff = None
+ return
+ }
+ let generation = self.generation
+ let original_identity = model.original.identity()
+ let modified_identity = model.modified.identity()
+ let original_version = model.original.get_version_id()
+ let modified_version = model.modified.get_version_id()
+ let original = model.original.snapshot()
+ let modified = model.modified.snapshot()
+ let computed = Some(
+ self.provider.compute_diff(original, modified, self.options),
+ ) catch {
+ _ => None
+ }
+ if self.disposed || self.generation != generation {
+ return
+ }
+ guard self.model is Some(current) &&
+ current.original.identity() == original_identity &&
+ current.modified.identity() == modified_identity &&
+ current.original.get_version_id() == original_version &&
+ current.modified.get_version_id() == modified_version else {
+ return
+ }
+ match computed {
+ Some(result) if valid_document_diff(result, original, modified) => {
+ self.current_diff = Some(result)
+ self.state = Ready
+ }
+ _ => {
+ self.current_diff = None
+ self.state = Failed
+ }
+ }
+ self.did_update.fire(())
+}
+
+///|
+pub fn DiffEditorViewModel::get_model(
+ self : DiffEditorViewModel,
+) -> DiffEditorModelData? {
+ self.model
+}
+
+///|
+pub fn DiffEditorViewModel::get_state(
+ self : DiffEditorViewModel,
+) -> DiffEditorViewModelState {
+ self.state
+}
+
+///|
+pub fn DiffEditorViewModel::get_diff(
+ self : DiffEditorViewModel,
+) -> @diff.DocumentDiff? {
+ self.current_diff
+}
+
+///|
+pub fn DiffEditorViewModel::is_diff_up_to_date(
+ self : DiffEditorViewModel,
+) -> Bool {
+ self.state is Ready
+}
+
+///|
+pub fn DiffEditorViewModel::on_did_update_diff(
+ self : DiffEditorViewModel,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.did_update.event(_ => listener())
+}
+
+///|
+fn DiffEditorViewModel::on_did_change_model(
+ self : DiffEditorViewModel,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.did_change_model.event(_ => listener())
+}
+
+///|
+pub fn DiffEditorViewModel::dispose(self : DiffEditorViewModel) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.generation += 1
+ self.immediate.dispose()
+ self.content_debounce.dispose()
+ self.dispose_model_subscriptions()
+ match self.provider_subscription {
+ Some(subscription) => subscription.dispose()
+ None => ()
+ }
+ self.provider_subscription = None
+ self.model = None
+ self.current_diff = None
+ self.state = NoModel
+ self.did_change_model.dispose()
+ self.did_update.dispose()
+}
+
+///|
+pub extend DiffEditorViewModelState with Eq::{equal, not_equal}
+
+///|
+pub extend DiffEditorViewModelState with Debug::{to_repr}
diff --git a/editor/internal/viewer/diff_editor/view_model_wbtest.mbt b/editor/internal/viewer/diff_editor/view_model_wbtest.mbt
new file mode 100644
index 000000000..5e50723b7
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/view_model_wbtest.mbt
@@ -0,0 +1,722 @@
+///|
+priv struct DiffViewModelTimerTask {
+ delay_ms : Int
+ callback : () -> Unit
+ active : Ref[Bool]
+}
+
+///|
+priv struct DiffViewModelFakeTimer {
+ tasks : Array[DiffViewModelTimerTask]
+}
+
+///|
+fn DiffViewModelFakeTimer::new() -> DiffViewModelFakeTimer {
+ { tasks: [] }
+}
+
+///|
+fn DiffViewModelFakeTimer::schedule_timeout(
+ self : DiffViewModelFakeTimer,
+ callback : () -> Unit,
+ delay_ms : Int,
+) -> @base_common.Disposable {
+ let active = Ref(true)
+ self.tasks.push({ delay_ms, callback, active })
+ @base_common.Disposable::from(() => active.val = false)
+}
+
+///|
+fn DiffViewModelFakeTimer::active_delays(
+ self : DiffViewModelFakeTimer,
+) -> Array[Int] {
+ self.tasks.filter_map(task => {
+ if task.active.val {
+ Some(task.delay_ms)
+ } else {
+ None
+ }
+ })
+}
+
+///|
+fn DiffViewModelFakeTimer::fire_next(
+ self : DiffViewModelFakeTimer,
+ delay_ms : Int,
+) -> Unit raise {
+ for task in self.tasks {
+ if task.active.val && task.delay_ms == delay_ms {
+ task.active.val = false
+ (task.callback)()
+ return
+ }
+ }
+ fail("no active diff ViewModel timer with delay \{delay_ms}ms")
+}
+
+///|
+priv enum FakeDocumentDiffResult {
+ Empty
+ OneChange
+ InvalidLineMapping
+ InvalidInnerMapping
+ InnerOutsideParent
+ OverlappingInnerMappings
+ Failure
+}
+
+///|
+priv suberror FakeDocumentDiffProviderError {
+ ComputeFailed
+}
+
+///|
+priv struct FakeDocumentDiffProvider {
+ result : Ref[FakeDocumentDiffResult]
+ calls : Ref[Int]
+ observed_ignore_trim_whitespace : Array[Bool]
+ observed_original_values : Array[String]
+ observed_modified_values : Array[String]
+ before_compute : Ref[(() -> Unit)?]
+ did_change : @base_common.Emitter[Unit]
+}
+
+///|
+fn FakeDocumentDiffProvider::new(
+ result? : FakeDocumentDiffResult = Empty,
+) -> FakeDocumentDiffProvider {
+ {
+ result: Ref(result),
+ calls: Ref(0),
+ observed_ignore_trim_whitespace: [],
+ observed_original_values: [],
+ observed_modified_values: [],
+ before_compute: Ref(None),
+ did_change: Emitter(),
+ }
+}
+
+///|
+fn FakeDocumentDiffProvider::signal_change(
+ self : FakeDocumentDiffProvider,
+) -> Unit {
+ self.did_change.fire(())
+}
+
+///|
+impl @diff.DocumentDiffProvider for FakeDocumentDiffProvider with fn compute_diff(
+ self,
+ original,
+ modified,
+ options,
+) {
+ self.calls.val += 1
+ self.observed_ignore_trim_whitespace.push(options.ignore_trim_whitespace)
+ self.observed_original_values.push(original.get_value())
+ self.observed_modified_values.push(modified.get_value())
+ match self.before_compute.val {
+ Some(callback) => {
+ self.before_compute.val = None
+ callback()
+ }
+ None => ()
+ }
+ match self.result.val {
+ Empty => { changes: [], additional_alignments: [] }
+ OneChange => {
+ let original_range = @base_common.LineRange(1, 2)
+ let modified_range = @base_common.LineRange(1, 2)
+ {
+ changes: [
+ DetailedLineRangeMapping(original_range, modified_range, None),
+ ],
+ additional_alignments: [],
+ }
+ }
+ InvalidLineMapping =>
+ {
+ changes: [
+ DetailedLineRangeMapping(LineRange(0, 2), LineRange(1, 2), None),
+ ],
+ additional_alignments: [],
+ }
+ InvalidInnerMapping =>
+ {
+ changes: [
+ DetailedLineRangeMapping(
+ LineRange(1, 2),
+ LineRange(1, 2),
+ Some([RangeMapping(Range(1, 1, 1, 99), Range(1, 1, 1, 2))]),
+ ),
+ ],
+ additional_alignments: [],
+ }
+ InnerOutsideParent =>
+ {
+ changes: [
+ DetailedLineRangeMapping(
+ LineRange(1, 2),
+ LineRange(1, 2),
+ Some([RangeMapping(Range(2, 1, 2, 2), Range(1, 1, 1, 2))]),
+ ),
+ ],
+ additional_alignments: [],
+ }
+ OverlappingInnerMappings =>
+ {
+ changes: [
+ DetailedLineRangeMapping(
+ LineRange(1, 2),
+ LineRange(1, 2),
+ Some([
+ RangeMapping(Range(1, 1, 1, 3), Range(1, 1, 1, 3)),
+ RangeMapping(Range(1, 2, 1, 3), Range(1, 2, 1, 3)),
+ ]),
+ ),
+ ],
+ additional_alignments: [],
+ }
+ Failure => raise FakeDocumentDiffProviderError::ComputeFailed
+ }
+}
+
+///|
+impl @diff.DocumentDiffProvider for FakeDocumentDiffProvider with fn on_did_change(
+ self,
+ listener,
+) {
+ self.did_change.event(_ => listener())
+}
+
+///|
+fn diff_view_model_test_model(name : String, text : String) -> @model.TextModel {
+ TextModel(
+ @base_common.Uri::file("/diff-view-model-\{name}.mbt"),
+ name,
+ "moonbit",
+ 1,
+ "rev-1",
+ text,
+ )
+}
+
+///|
+fn new_diff_view_model_for_test(
+ provider : &@diff.DocumentDiffProvider,
+ timer : DiffViewModelFakeTimer,
+) -> DiffEditorViewModel {
+ DiffEditorViewModel(provider, schedule_timeout=(callback, delay_ms) => {
+ timer.schedule_timeout(callback, delay_ms)
+ })
+}
+
+///|
+test "DiffEditorViewModel uses immediate and content debounce scheduler lanes" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=OneChange)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let original = diff_view_model_test_model("scheduler-original", "before")
+ let modified = diff_view_model_test_model("scheduler-modified", "after")
+ let updates = Ref(0)
+ let update_subscription = view_model.on_did_update_diff(() => updates.val += 1)
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ assert_true(view_model.get_state() == Pending)
+ assert_true(timer.active_delays() == [0])
+ assert_eq(provider.calls.val, 0)
+ assert_eq(updates.val, 1)
+
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Ready)
+ assert_true(view_model.is_diff_up_to_date())
+ assert_eq(provider.calls.val, 1)
+ assert_eq(view_model.get_diff().unwrap().changes.length(), 1)
+ assert_eq(updates.val, 2)
+
+ modified.set_value("after one")
+ assert_true(view_model.get_state() == Pending)
+ assert_true(view_model.get_diff() is None)
+ assert_true(timer.active_delays() == [200])
+ modified.set_value("after two")
+ assert_true(timer.active_delays() == [200])
+ assert_eq(provider.calls.val, 1)
+
+ timer.fire_next(200)
+ assert_true(view_model.get_state() == Ready)
+ assert_eq(provider.calls.val, 2)
+ assert_eq(provider.observed_modified_values[1], "after two")
+ assert_true(timer.active_delays().is_empty())
+
+ update_subscription.dispose()
+ view_model.dispose()
+ original.dispose()
+ modified.dispose()
+}
+
+///|
+test "DiffEditorViewModel treats the same model pair as a complete no-op" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new()
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let original = diff_view_model_test_model("same-original", "a")
+ let modified = diff_view_model_test_model("same-modified", "b")
+ let updates = Ref(0)
+ let update_subscription = view_model.on_did_update_diff(() => updates.val += 1)
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ timer.fire_next(0)
+ let generation = view_model.generation
+ let task_count = timer.tasks.length()
+ let returned = view_model.set_model(Some({ original, modified }))
+
+ assert_true(returned is None)
+ assert_eq(view_model.generation, generation)
+ assert_eq(timer.tasks.length(), task_count)
+ assert_eq(provider.calls.val, 1)
+ assert_eq(updates.val, 2)
+ assert_true(view_model.get_state() == Ready)
+
+ update_subscription.dispose()
+ view_model.dispose()
+ original.dispose()
+ modified.dispose()
+}
+
+///|
+test "DiffEditorViewModel replaces provider subscription and recomputes immediately" {
+ let timer = DiffViewModelFakeTimer::new()
+ let first = FakeDocumentDiffProvider::new()
+ let second = FakeDocumentDiffProvider::new(result=OneChange)
+ let first_object : &@diff.DocumentDiffProvider = first
+ let second_object : &@diff.DocumentDiffProvider = second
+ let view_model = new_diff_view_model_for_test(first_object, timer)
+ let original = diff_view_model_test_model("provider-original", "a")
+ let modified = diff_view_model_test_model("provider-modified", "b")
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ timer.fire_next(0)
+ assert_eq(first.calls.val, 1)
+ assert_true(first.did_change.has_listeners())
+
+ view_model.set_provider(second_object)
+ assert_false(first.did_change.has_listeners())
+ assert_true(second.did_change.has_listeners())
+ assert_true(view_model.get_state() == Pending)
+ assert_true(timer.active_delays() == [0])
+ first.signal_change()
+ assert_true(timer.active_delays() == [0])
+
+ timer.fire_next(0)
+ assert_eq(first.calls.val, 1)
+ assert_eq(second.calls.val, 1)
+ assert_eq(view_model.get_diff().unwrap().changes.length(), 1)
+
+ second.result.val = Empty
+ second.signal_change()
+ assert_true(view_model.get_state() == Pending)
+ assert_true(timer.active_delays() == [0])
+ timer.fire_next(0)
+ assert_eq(second.calls.val, 2)
+ assert_true(view_model.get_diff().unwrap().changes.is_empty())
+
+ view_model.dispose()
+ original.dispose()
+ modified.dispose()
+}
+
+///|
+test "DiffEditorViewModel converts provider errors and invalid mappings to Failed" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=Failure)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let original = diff_view_model_test_model("failure-original", "abc\nsecond")
+ let modified = diff_view_model_test_model("failure-modified", "xyz\nsecond")
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Failed)
+ assert_false(view_model.is_diff_up_to_date())
+ assert_true(view_model.get_diff() is None)
+
+ provider.result.val = InvalidLineMapping
+ provider.signal_change()
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Failed)
+ assert_true(view_model.get_diff() is None)
+
+ provider.result.val = InvalidInnerMapping
+ provider.signal_change()
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Failed)
+ assert_true(view_model.get_diff() is None)
+
+ provider.result.val = InnerOutsideParent
+ provider.signal_change()
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Failed)
+ assert_true(view_model.get_diff() is None)
+
+ provider.result.val = OverlappingInnerMappings
+ provider.signal_change()
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Failed)
+ assert_true(view_model.get_diff() is None)
+
+ provider.result.val = OneChange
+ provider.signal_change()
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Ready)
+ assert_eq(view_model.get_diff().unwrap().changes.length(), 1)
+
+ view_model.dispose()
+ original.dispose()
+ modified.dispose()
+}
+
+///|
+test "validator accepts Core inner anchors on empty hunk sides" {
+ let provider = @diff.get_core_document_diff_provider()
+ let original = @model.TextSnapshot(
+ [
+ "fn navigation_anchors() {", " let stable_head = 0", " let deleted_only = 1",
+ " let stable_middle_one = 2", " let stable_middle_two = 3", " let stable_tail = 5",
+ "}",
+ ].join("\n"),
+ )
+ let modified = @model.TextSnapshot(
+ [
+ "fn navigation_anchors() {", " let stable_head = 0", " let stable_middle_one = 2",
+ " let stable_middle_two = 3", " let inserted_only = 4", " let stable_tail = 5",
+ "}",
+ ].join("\n"),
+ )
+ let result = provider.compute_diff(original, modified, {
+ ignore_trim_whitespace: true,
+ })
+ assert_eq(result.changes.length(), 2)
+ assert_true(result.changes[0].modified.is_empty())
+ assert_true(result.changes[1].original.is_empty())
+ assert_true(valid_document_diff(result, original, modified))
+
+ let fragmented_original = StringBuilder()
+ let fragmented_modified = StringBuilder()
+ for fragment in 0..<20 {
+ for line in 0..<130 {
+ fragmented_original.write_string(
+ "let fragment_\{fragment}_deleted_\{line} = \{line}\n",
+ )
+ }
+ let retained = "let fragment_\{fragment}_retained = \{fragment}\n"
+ fragmented_original.write_string(retained)
+ fragmented_modified.write_string(retained)
+ }
+ let fragmented_original = @model.TextSnapshot(fragmented_original.to_string())
+ let fragmented_modified = @model.TextSnapshot(fragmented_modified.to_string())
+ let fragmented = provider.compute_diff(
+ fragmented_original,
+ fragmented_modified,
+ { ignore_trim_whitespace: true },
+ )
+ assert_eq(fragmented.changes.length(), 20)
+ assert_true(
+ valid_document_diff(fragmented, fragmented_original, fragmented_modified),
+ )
+}
+
+///|
+test "DiffEditorViewModel rejects a result made stale by reentrant provider change" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=OneChange)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let original = diff_view_model_test_model("reentrant-original", "a")
+ let modified = diff_view_model_test_model("reentrant-modified", "b")
+ provider.before_compute.val = Some(() => provider.signal_change())
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ timer.fire_next(0)
+ assert_eq(provider.calls.val, 1)
+ assert_true(view_model.get_state() == Pending)
+ assert_true(view_model.get_diff() is None)
+ assert_true(timer.active_delays() == [0])
+
+ timer.fire_next(0)
+ assert_eq(provider.calls.val, 2)
+ assert_true(view_model.get_state() == Ready)
+ assert_eq(view_model.get_diff().unwrap().changes.length(), 1)
+
+ view_model.dispose()
+ original.dispose()
+ modified.dispose()
+}
+
+///|
+test "DiffEditorViewModel dispose cancels tasks subscriptions and stale callbacks" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=OneChange)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let original = diff_view_model_test_model("dispose-original", "a")
+ let modified = diff_view_model_test_model("dispose-modified", "b")
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ assert_true(timer.active_delays() == [0])
+ let stale_callback = timer.tasks[0].callback
+ assert_true(provider.did_change.has_listeners())
+ view_model.dispose()
+
+ assert_true(timer.active_delays().is_empty())
+ assert_false(provider.did_change.has_listeners())
+ assert_true(view_model.get_state() == NoModel)
+ assert_true(view_model.get_model() is None)
+ assert_true(view_model.get_diff() is None)
+ stale_callback()
+ modified.set_value("changed after dispose")
+ provider.signal_change()
+ assert_eq(provider.calls.val, 0)
+ assert_true(timer.active_delays().is_empty())
+ assert_false(original.is_disposed())
+ assert_false(modified.is_disposed())
+
+ original.dispose()
+ modified.dispose()
+}
+
+///|
+test "DiffEditorViewModel returns a fully detached old pair for immediate disposal" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=OneChange)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let first_original = diff_view_model_test_model("old-original", "old left")
+ let first_modified = diff_view_model_test_model("old-modified", "old right")
+ let next_original = diff_view_model_test_model("new-original", "new left")
+ let next_modified = diff_view_model_test_model("new-modified", "new right")
+
+ view_model.set_model(
+ Some({ original: first_original, modified: first_modified }),
+ )
+ |> ignore
+ timer.fire_next(0)
+ let returned = view_model.set_model(
+ Some({ original: next_original, modified: next_modified }),
+ )
+ guard returned is Some(returned) else {
+ fail("replacing a model pair must return the detached old pair")
+ }
+ assert_true(returned.original.same(first_original))
+ assert_true(returned.modified.same(first_modified))
+
+ returned.original.dispose()
+ returned.modified.dispose()
+ assert_true(first_original.is_disposed())
+ assert_true(first_modified.is_disposed())
+ timer.fire_next(0)
+
+ assert_true(view_model.get_state() == Ready)
+ assert_eq(provider.observed_original_values[1], "new left")
+ assert_eq(provider.observed_modified_values[1], "new right")
+ guard view_model.get_model() is Some(current) else {
+ fail("new model pair must remain installed")
+ }
+ assert_true(current.original.same(next_original))
+ assert_true(current.modified.same(next_modified))
+
+ view_model.dispose()
+ next_original.dispose()
+ next_modified.dispose()
+}
+
+///|
+test "one model will-dispose atomically clears the pair and both pane slots" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=OneChange)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let original = diff_view_model_test_model("will-dispose-original", "old left")
+ let modified = diff_view_model_test_model(
+ "will-dispose-modified", "old right",
+ )
+ let original_slot : Ref[@model.TextModel?] = Ref(None)
+ let modified_slot : Ref[@model.TextModel?] = Ref(None)
+ let pane_commit = new_diff_editor_model_pair_commit_barrier()
+ let model_changes = Ref(0)
+ let update_count = Ref(0)
+ let every_update_saw_synchronized_slots = Ref(true)
+ let model_subscription = view_model.on_did_change_model(() => {
+ model_changes.val += 1
+ let generation_before = pane_commit.committed_generation
+ commit_diff_editor_model_pair_transaction(pane_commit, () => {
+ commit_diff_editor_model_pair(
+ pane_commit,
+ view_model.get_model(),
+ model => {
+ // Assert in the first synchronous setter callback, not after both
+ // slots happen to contain the expected values.
+ if !pane_commit.is_committing ||
+ pane_commit.committed_generation != generation_before {
+ abort("original setter escaped the pair commit barrier")
+ }
+ original_slot.val = model
+ },
+ model => {
+ if !pane_commit.is_committing ||
+ pane_commit.committed_generation != generation_before {
+ abort("modified setter observed an early committed generation")
+ }
+ modified_slot.val = model
+ },
+ )
+ })
+ })
+ let update_subscription = view_model.on_did_update_diff(() => {
+ if pane_commit.is_committing {
+ abort("outer diff update escaped before both pane setters committed")
+ }
+ update_count.val += 1
+ let synchronized = match
+ (view_model.get_model(), original_slot.val, modified_slot.val) {
+ (None, None, None) => true
+ (Some(current), Some(slot_original), Some(slot_modified)) =>
+ current.original.same(slot_original) &&
+ current.modified.same(slot_modified)
+ _ => false
+ }
+ if !synchronized {
+ every_update_saw_synchronized_slots.val = false
+ }
+ })
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ guard (original_slot.val, modified_slot.val)
+ is (Some(installed_original), Some(installed_modified)) else {
+ fail("both pane slots must receive the installed pair")
+ }
+ assert_true(installed_original.same(original))
+ assert_true(installed_modified.same(modified))
+ timer.fire_next(0)
+ assert_true(view_model.get_state() == Ready)
+ assert_true(view_model.get_diff() is Some(_))
+ assert_eq(provider.calls.val, 1)
+
+ modified.set_value("pending right")
+ assert_true(timer.active_delays() == [200])
+ let stale_content_callback = timer.tasks.last().unwrap().callback
+ original.dispose()
+
+ assert_true(view_model.get_model() is None)
+ assert_true(view_model.get_state() == NoModel)
+ assert_true(view_model.get_diff() is None)
+ assert_true(original_slot.val is None)
+ assert_true(modified_slot.val is None)
+ assert_true(timer.active_delays().is_empty())
+ assert_eq(model_changes.val, 2)
+ assert_false(pane_commit.is_committing)
+ assert_eq(pane_commit.committed_generation, 2)
+ assert_eq(update_count.val, 4)
+ assert_true(every_update_saw_synchronized_slots.val)
+ assert_true(original.is_disposed())
+ assert_false(modified.is_disposed())
+
+ // The live sibling was detached, not consumed or disposed. Its content and
+ // a canceled timer callback cannot resurrect work for the invalidated pair.
+ modified.set_value("changed after sibling disposal")
+ stale_content_callback()
+ assert_eq(provider.calls.val, 1)
+ assert_true(timer.active_delays().is_empty())
+ assert_true(view_model.get_model() is None)
+
+ model_subscription.dispose()
+ update_subscription.dispose()
+ view_model.dispose()
+ modified.dispose()
+}
+
+///|
+test "modified model will-dispose invalidates the whole pair too" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=OneChange)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let original = diff_view_model_test_model("live-original", "left")
+ let modified = diff_view_model_test_model("disposed-modified", "right")
+
+ view_model.set_model(Some({ original, modified })) |> ignore
+ timer.fire_next(0)
+ modified.dispose()
+
+ assert_true(view_model.get_model() is None)
+ assert_true(view_model.get_state() == NoModel)
+ assert_true(view_model.get_diff() is None)
+ assert_true(timer.active_delays().is_empty())
+ assert_false(original.is_disposed())
+ assert_true(modified.is_disposed())
+
+ view_model.dispose()
+ original.dispose()
+}
+
+///|
+test "replacement owns the only detached-pair return and old disposal is inert" {
+ let timer = DiffViewModelFakeTimer::new()
+ let provider = FakeDocumentDiffProvider::new(result=OneChange)
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let view_model = new_diff_view_model_for_test(provider_object, timer)
+ let first_original = diff_view_model_test_model("replace-count-original", "a")
+ let first_modified = diff_view_model_test_model("replace-count-modified", "b")
+ let next_original = diff_view_model_test_model("replace-next-original", "c")
+ let next_modified = diff_view_model_test_model("replace-next-modified", "d")
+ let first_original_disposes = Ref(0)
+ let first_modified_disposes = Ref(0)
+ let original_dispose_subscription = first_original.on_will_dispose(() => {
+ first_original_disposes.val += 1
+ })
+ let modified_dispose_subscription = first_modified.on_will_dispose(() => {
+ first_modified_disposes.val += 1
+ })
+
+ view_model.set_model(
+ Some({ original: first_original, modified: first_modified }),
+ )
+ |> ignore
+ let stale_first_compute = timer.tasks.last().unwrap().callback
+ let returned = view_model.set_model(
+ Some({ original: next_original, modified: next_modified }),
+ )
+ guard returned is Some(returned) else {
+ fail("replacement must return exactly the fully detached old pair")
+ }
+ let replacement_task_count = timer.tasks.length()
+ returned.original.set_value("detached a")
+ returned.modified.set_value("detached b")
+ assert_eq(timer.tasks.length(), replacement_task_count)
+ assert_true(timer.active_delays() == [0])
+ returned.original.dispose()
+ returned.modified.dispose()
+ assert_eq(first_original_disposes.val, 1)
+ assert_eq(first_modified_disposes.val, 1)
+
+ // Disposal of a pair whose subscriptions were retired cannot clear the new
+ // pair, and even an explicitly invoked stale callback cannot compute it.
+ stale_first_compute()
+ assert_eq(provider.calls.val, 0)
+ guard view_model.get_model() is Some(current) else {
+ fail("the replacement pair must remain installed")
+ }
+ assert_true(current.original.same(next_original))
+ assert_true(current.modified.same(next_modified))
+ assert_true(timer.active_delays() == [0])
+ timer.fire_next(0)
+ assert_eq(provider.calls.val, 1)
+
+ original_dispose_subscription.dispose()
+ modified_dispose_subscription.dispose()
+ view_model.dispose()
+ assert_false(next_original.is_disposed())
+ assert_false(next_modified.is_disposed())
+ next_original.dispose()
+ next_modified.dispose()
+}
diff --git a/editor/internal/viewer/diff_editor/widget.mbt b/editor/internal/viewer/diff_editor/widget.mbt
new file mode 100644
index 000000000..d09db41b1
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/widget.mbt
@@ -0,0 +1,1361 @@
+///|
+/// A first-class diff widget that owns two code-editor kernels and one shared
+/// diff ViewModel. Product-specific algorithm controls and document
+/// presentation routing are intentionally outside this package.
+pub struct DiffEditorWidget {
+ host : @rdom.Element
+ root : @rdom.Element
+ panes : @rdom.Element
+ original_host : @rdom.Element
+ modified_host : @rdom.Element
+ sash : @rdom.Element
+ live_region : @rdom.Element
+ editors : DiffEditorEditors
+ view_model : DiffEditorViewModel
+ mut editor_options : @code_widget.CodeEditorOptions
+ mut options : DiffEditorOptions
+ layout_state : DiffEditorLayoutState
+ pointer_monitor : @base_browser.GlobalPointerMoveMonitor
+ lifetime : Array[@base_common.Disposable]
+ mut resize_observer : @base_common.Disposable?
+ mut overview_ruler : @code_widget.CodeEditorOverviewRuler?
+ mut original_decoration_ids : Array[String]
+ mut modified_decoration_ids : Array[String]
+ mut original_alignment_zone_ids : Array[String]
+ mut modified_alignment_zone_ids : Array[String]
+ mut inline_deleted_render_context : @code_widget.VirtualizedCodeEditorRenderLinesContext?
+ mut inline_deleted_renderers : Array[InlineDeletedRenderer]
+ mut original_alignment_geometry : PaneGeometrySnapshot?
+ mut modified_alignment_geometry : PaneGeometrySnapshot?
+ mut projection : DiffFeatureProjection?
+ mut navigation_index : Int?
+ mut reconciling_generation : Int
+ mut is_reconciling : Bool
+ /// Cancellable paint barrier for the latest outer layout transaction.
+ /// Side-by-side waits for both kernels; Inline waits only for its visible
+ /// modified kernel and never measures the hidden original.
+ layout_render_waiters : Array[@base_common.Disposable]
+ mut layout_render_generation : Int
+ mut layout_render_remaining : Int
+ mut has_valid_layout_box : Bool
+ mut is_syncing_scroll : Bool
+ /// Internal, component-local lifecycle trace. It is exposed only through
+ /// the internal package diagnostic snapshot and never through a public
+ /// editor facade.
+ mut dispose_pass_count : Int
+ mut resize_observer_disposed : Bool
+ mut overview_ruler_disposed : Bool
+ mut inline_render_context_disposed : Bool
+ mut disposed : Bool
+}
+
+///|
+fn diff_whole_line_decorations(
+ ranges : Array[@base_common.Range],
+ line_class : String,
+ gutter_class : String,
+ description : String,
+) -> Array[@model.ModelDeltaDecoration] {
+ ranges.map(range => {
+ range,
+ options: ModelDecorationOptions(
+ line_class,
+ is_whole_line=true,
+ lines_decorations_class_name=gutter_class,
+ description~,
+ ),
+ })
+}
+
+///|
+fn diff_inner_decorations(
+ ranges : Array[@base_common.Range],
+ inline_class : String,
+ description : String,
+) -> Array[@model.ModelDeltaDecoration] {
+ ranges.map(range => {
+ range,
+ options: ModelDecorationOptions(
+ "",
+ inline_class_name=inline_class,
+ description~,
+ ),
+ })
+}
+
+///|
+pub fn DiffEditorWidget::create(
+ host : @rdom.Element,
+ services? : @code_widget.CodeEditorServices,
+ editor_options? : @code_widget.CodeEditorOptions = @code_widget.CodeEditorOptions::default(),
+ options? : DiffEditorOptions = DiffEditorOptions::default(),
+ provider : &@diff.DocumentDiffProvider,
+) -> DiffEditorWidget {
+ let document = @rdom.document()
+ let root = document.create_element("section")
+ root.set_attribute("class", "moonbit-diff-editor")
+ root.set_attribute("role", "region")
+ root.set_attribute("aria-label", "File comparison")
+ let panes = document.create_element("div")
+ panes.set_attribute("class", "moonbit-diff-editor-panes")
+ let original_host = document.create_element("div")
+ original_host.set_attribute(
+ "class", "moonbit-diff-editor-pane moonbit-diff-editor-original",
+ )
+ original_host.set_attribute("role", "group")
+ original_host.set_attribute("aria-label", "Original file")
+ let sash = document.create_element("div")
+ sash.set_attribute("class", "moonbit-diff-editor-sash")
+ sash.set_attribute("role", "separator")
+ sash.set_attribute("aria-label", "Resize diff panes")
+ sash.set_attribute("aria-orientation", "vertical")
+ sash.set_attribute("aria-valuemin", "10")
+ sash.set_attribute("aria-valuemax", "90")
+ sash.set_attribute("tabindex", "0")
+ let modified_host = document.create_element("div")
+ modified_host.set_attribute(
+ "class", "moonbit-diff-editor-pane moonbit-diff-editor-modified",
+ )
+ modified_host.set_attribute("role", "group")
+ modified_host.set_attribute("aria-label", "Modified file")
+ panes.append_child(original_host.as_node())
+ panes.append_child(sash.as_node())
+ panes.append_child(modified_host.as_node())
+ let live_region = document.create_element("div")
+ live_region.set_attribute("class", "moonbit-diff-editor-live-region")
+ live_region.set_attribute("aria-live", "polite")
+ live_region.set_attribute("aria-atomic", "true")
+ root.append_child(panes.as_node())
+ root.append_child(live_region.as_node())
+ host.append_child(root.as_node())
+
+ let editors = DiffEditorEditors::create(
+ original_host,
+ modified_host,
+ services?,
+ editor_options,
+ options.diff_word_wrap,
+ )
+ let view_model = DiffEditorViewModel(provider, options={
+ ignore_trim_whitespace: options.ignore_trim_whitespace,
+ })
+ let widget : DiffEditorWidget = {
+ host,
+ root,
+ panes,
+ original_host,
+ modified_host,
+ sash,
+ live_region,
+ editors,
+ view_model,
+ editor_options,
+ options,
+ layout_state: DiffEditorLayoutState::new(options),
+ pointer_monitor: GlobalPointerMoveMonitor(),
+ lifetime: [],
+ resize_observer: None,
+ overview_ruler: None,
+ original_decoration_ids: [],
+ modified_decoration_ids: [],
+ original_alignment_zone_ids: [],
+ modified_alignment_zone_ids: [],
+ inline_deleted_render_context: None,
+ inline_deleted_renderers: [],
+ original_alignment_geometry: None,
+ modified_alignment_geometry: None,
+ projection: None,
+ navigation_index: None,
+ reconciling_generation: 0,
+ is_reconciling: false,
+ layout_render_waiters: [],
+ layout_render_generation: 0,
+ layout_render_remaining: 0,
+ has_valid_layout_box: false,
+ is_syncing_scroll: false,
+ dispose_pass_count: 0,
+ resize_observer_disposed: false,
+ overview_ruler_disposed: false,
+ inline_render_context_disposed: false,
+ disposed: false,
+ }
+ widget.install_event_bridges()
+ // Establish accessibility/visibility attributes even when the initial host
+ // is display:none and therefore has no valid layout transaction yet.
+ widget.apply_layout_dom()
+ widget.sync_overview_ruler_option()
+ widget.resize_observer = @base_browser.observe_element_resize(host, () => {
+ widget.layout()
+ })
+ widget.layout()
+ widget
+}
+
+///|
+fn DiffEditorWidget::listen(
+ self : DiffEditorWidget,
+ target : @rdom.EventTarget,
+ event_name : String,
+ listener : (@rdom.Event) -> Unit,
+) -> Unit {
+ target.add_event_listener(event_name, listener)
+ self.lifetime.push(
+ @base_common.Disposable::from(() => {
+ target.remove_event_listener(event_name, listener)
+ }),
+ )
+}
+
+///|
+fn DiffEditorWidget::host_has_positive_layout_box(
+ self : DiffEditorWidget,
+) -> Bool {
+ let rect = self.host.get_bounding_client_rect()
+ rect.get_width() > 0.0 && rect.get_height() > 0.0
+}
+
+///|
+fn DiffEditorWidget::cancel_layout_render_barrier(
+ self : DiffEditorWidget,
+) -> Unit {
+ for waiter in self.layout_render_waiters {
+ waiter.dispose()
+ }
+ self.layout_render_waiters.clear()
+ self.layout_render_remaining = 0
+}
+
+///|
+fn DiffEditorWidget::complete_layout_pane_render(
+ self : DiffEditorWidget,
+ generation : Int,
+) -> Unit {
+ if self.disposed ||
+ generation != self.layout_render_generation ||
+ self.layout_render_remaining <= 0 {
+ return
+ }
+ self.layout_render_remaining -= 1
+ if self.layout_render_remaining > 0 {
+ return
+ }
+ self.cancel_layout_render_barrier()
+ if !self.host_has_positive_layout_box() {
+ self.has_valid_layout_box = false
+ return
+ }
+ self.has_valid_layout_box = true
+ // Every outer layout, including height-only resizes, gets exactly one
+ // reconcile using geometry from the completed pane render(s).
+ self.reconcile_view_model(geometry_refresh=true)
+}
+
+///|
+fn DiffEditorWidget::layout_editors_after_render(
+ self : DiffEditorWidget,
+) -> Unit {
+ self.cancel_layout_render_barrier()
+ if !self.has_valid_layout_box || !self.host_has_positive_layout_box() {
+ self.has_valid_layout_box = false
+ return
+ }
+ self.layout_render_generation += 1
+ let generation = self.layout_render_generation
+ self.layout_render_remaining = if self.layout_state.actual_layout ==
+ SideBySide {
+ 2
+ } else {
+ 1
+ }
+ if self.layout_state.actual_layout == SideBySide {
+ self.layout_render_waiters.push(
+ self.editors.original.after_next_render(() => {
+ self.complete_layout_pane_render(generation)
+ }),
+ )
+ }
+ self.layout_render_waiters.push(
+ self.editors.modified.after_next_render(() => {
+ self.complete_layout_pane_render(generation)
+ }),
+ )
+ self.editors.layout(self.layout_state.actual_layout)
+}
+
+///|
+fn DiffEditorWidget::install_event_bridges(self : DiffEditorWidget) -> Unit {
+ self.lifetime.push(
+ self.view_model.on_did_change_model(() => {
+ self.editors.set_model(self.view_model.get_model())
+ }),
+ )
+ self.lifetime.push(
+ self.view_model.on_did_update_diff(() => self.reconcile_view_model()),
+ )
+ self.lifetime.push(
+ self.editors.original.on_did_scroll_change(event => {
+ if self.layout_state.actual_layout == SideBySide &&
+ event.scroll_top_changed {
+ self.sync_scroll_top_to(self.editors.modified, event.scroll_top)
+ }
+ if self.layout_state.actual_layout == SideBySide &&
+ event.scroll_left_changed {
+ self.sync_scroll_left_to(self.editors.modified, event.scroll_left)
+ }
+ }),
+ )
+ self.lifetime.push(
+ self.editors.modified.on_did_scroll_change(event => {
+ if self.layout_state.actual_layout == SideBySide &&
+ event.scroll_top_changed {
+ self.sync_scroll_top_to(self.editors.original, event.scroll_top)
+ }
+ if self.layout_state.actual_layout == SideBySide &&
+ event.scroll_left_changed {
+ self.sync_scroll_left_to(self.editors.original, event.scroll_left)
+ }
+ }),
+ )
+ self.lifetime.push(
+ self.editors.original.on_did_change_view_zones(() => {
+ self.reconcile_geometry_if_changed()
+ }),
+ )
+ self.lifetime.push(
+ self.editors.modified.on_did_change_view_zones(() => {
+ self.reconcile_geometry_if_changed()
+ }),
+ )
+ self.lifetime.push(
+ self.editors.original.on_did_change_hidden_areas(() => {
+ self.reconcile_geometry_if_changed()
+ }),
+ )
+ self.lifetime.push(
+ self.editors.modified.on_did_change_hidden_areas(() => {
+ self.reconcile_geometry_if_changed()
+ }),
+ )
+ self.listen(self.root.as_event_target(), "keydown", event => {
+ guard event.to_keyboard_event() is Some(keyboard) else { return }
+ if keyboard.key() == "F7" {
+ event.prevent_default()
+ event.stop_propagation()
+ if keyboard.shift_key() {
+ self.reveal_previous_change() |> ignore
+ } else {
+ self.reveal_next_change() |> ignore
+ }
+ }
+ })
+ self.listen(self.sash.as_event_target(), "pointerdown", event => {
+ guard event.to_mouse_event() is Some(mouse) && mouse.get_button() == 0 else {
+ return
+ }
+ if !self.options.sash_enabled ||
+ self.layout_state.actual_layout != SideBySide {
+ return
+ }
+ event.prevent_default()
+ event.stop_propagation()
+ let root_left = self.panes.get_bounding_client_rect().get_left()
+ self.pointer_monitor.start_monitoring(
+ self.sash,
+ @base_browser.mouse_event_pointer_id(mouse),
+ mouse.get_buttons(),
+ pointer_event => {
+ if self.layout_state.set_split_from_pointer(
+ Double::from_int(pointer_event.get_client_x()) - root_left,
+ ) {
+ self.apply_layout_dom()
+ self.layout_editors_after_render()
+ }
+ },
+ _ => (),
+ )
+ })
+ self.listen(self.sash.as_event_target(), "keydown", event => {
+ guard event.to_keyboard_event() is Some(keyboard) else { return }
+ let direction = match keyboard.key() {
+ "ArrowLeft" => -1.0
+ "ArrowRight" => 1.0
+ _ => return
+ }
+ if !self.options.sash_enabled ||
+ self.layout_state.actual_layout != SideBySide {
+ return
+ }
+ event.prevent_default()
+ event.stop_propagation()
+ let next_offset = self.layout_state.width *
+ (self.layout_state.split_ratio + direction * 0.02)
+ if self.layout_state.set_split_from_pointer(next_offset) {
+ self.apply_layout_dom()
+ self.layout_editors_after_render()
+ }
+ })
+}
+
+///|
+fn DiffEditorWidget::sync_scroll_top_to(
+ self : DiffEditorWidget,
+ target : @code_widget.CodeEditorWidget,
+ scroll_top : Double,
+) -> Unit {
+ if self.disposed ||
+ self.is_syncing_scroll ||
+ (target.get_scroll_top() - scroll_top).abs() <= 0.5 {
+ return
+ }
+ self.is_syncing_scroll = true
+ target.set_scroll_top(scroll_top)
+ self.is_syncing_scroll = false
+}
+
+///|
+fn DiffEditorWidget::sync_scroll_left_to(
+ self : DiffEditorWidget,
+ target : @code_widget.CodeEditorWidget,
+ scroll_left : Double,
+) -> Unit {
+ if self.disposed ||
+ self.is_syncing_scroll ||
+ (target.get_scroll_left() - scroll_left).abs() <= 0.5 {
+ return
+ }
+ self.is_syncing_scroll = true
+ target.set_scroll_left(scroll_left)
+ self.is_syncing_scroll = false
+}
+
+///|
+fn DiffEditorWidget::clear_decorations(self : DiffEditorWidget) -> Unit {
+ self.original_decoration_ids = self.editors.original.delta_decorations(
+ self.original_decoration_ids,
+ [],
+ )
+ self.modified_decoration_ids = self.editors.modified.delta_decorations(
+ self.modified_decoration_ids,
+ [],
+ )
+}
+
+///|
+fn DiffEditorWidget::dispose_inline_deleted_renderers(
+ self : DiffEditorWidget,
+) -> Unit {
+ match self.inline_deleted_render_context {
+ Some(context) => {
+ context.dispose()
+ self.inline_render_context_disposed = context.is_disposed()
+ }
+ None =>
+ for entry in self.inline_deleted_renderers {
+ entry.renderer.dispose()
+ }
+ }
+ self.inline_deleted_renderers.clear()
+ self.inline_deleted_render_context = None
+}
+
+///|
+fn DiffEditorWidget::apply_projection_decorations(
+ self : DiffEditorWidget,
+ projection : DiffFeatureProjection,
+) -> Unit {
+ let original_projection = projection.original_decorations()
+ let modified_projection = projection.modified_decorations()
+ let original_decorations = diff_whole_line_decorations(
+ original_projection.whole_line_ranges(),
+ "diff-editor-line-delete",
+ "diff-editor-gutter-delete",
+ "diff-editor-original-line",
+ )
+ original_decorations.append(
+ diff_inner_decorations(
+ original_projection.inner_ranges(),
+ "diff-editor-char-delete",
+ "diff-editor-original-character",
+ ),
+ )
+ let modified_decorations = diff_whole_line_decorations(
+ modified_projection.whole_line_ranges(),
+ "diff-editor-line-insert",
+ "diff-editor-gutter-insert",
+ "diff-editor-modified-line",
+ )
+ modified_decorations.append(
+ diff_inner_decorations(
+ modified_projection.inner_ranges(),
+ "diff-editor-char-insert",
+ "diff-editor-modified-character",
+ ),
+ )
+ self.original_decoration_ids = self.editors.original.delta_decorations(
+ self.original_decoration_ids,
+ original_decorations,
+ )
+ self.modified_decoration_ids = self.editors.modified.delta_decorations(
+ self.modified_decoration_ids,
+ modified_decorations,
+ )
+}
+
+///|
+fn alignment_view_zone_specs(
+ spacers : Array[ManagedAlignmentSpacer],
+ pane_class : String,
+) -> Array[@code_widget.ManagedCodeEditorViewZoneSpec] {
+ spacers.map(spacer => {
+ let node = @rdom.document().create_element("div")
+ node.set_attribute(
+ "class",
+ "moonbit-diff-editor-alignment-spacer \{pane_class}",
+ )
+ node.set_attribute("aria-hidden", "true")
+ ManagedCodeEditorViewZoneSpec(
+ spacer.after_line_number,
+ spacer.height_in_px,
+ node,
+ )
+ })
+}
+
+///|
+fn replace_alignment_zones(
+ editor : @code_widget.CodeEditorWidget,
+ previous_ids : Array[String],
+ specs : Array[@code_widget.ManagedCodeEditorViewZoneSpec],
+ generation : Int,
+) -> Array[String] {
+ if previous_ids.is_empty() && specs.is_empty() {
+ []
+ } else {
+ editor.apply_managed_view_zones(previous_ids, specs, generation)
+ }
+}
+
+///|
+fn DiffEditorWidget::capture_alignment_geometry(
+ self : DiffEditorWidget,
+) -> (PaneGeometrySnapshot?, PaneGeometrySnapshot?) {
+ (
+ self.editors.original
+ .geometry_snapshot(self.original_alignment_zone_ids)
+ .map(pane_geometry_from_code_editor),
+ self.editors.modified
+ .geometry_snapshot(self.modified_alignment_zone_ids)
+ .map(pane_geometry_from_code_editor),
+ )
+}
+
+///|
+fn DiffEditorWidget::restore_stable_viewports(
+ self : DiffEditorWidget,
+ original : @code_widget.StableCodeEditorViewportState?,
+ modified : @code_widget.StableCodeEditorViewportState?,
+ inline_modified : @code_widget.CodeEditorViewState?,
+) -> Unit {
+ match original {
+ Some(state) =>
+ self.editors.original.restore_stable_viewport_state(state) |> ignore
+ None => ()
+ }
+ match modified {
+ Some(state) =>
+ self.editors.modified.restore_stable_viewport_state(state) |> ignore
+ None => ()
+ }
+ match inline_modified {
+ Some(state) => self.editors.modified.restore_view_state(Some(state))
+ None => ()
+ }
+}
+
+///|
+fn DiffEditorWidget::clear_overview_ruler(
+ self : DiffEditorWidget,
+ generation : Int,
+) -> Unit {
+ match self.overview_ruler {
+ Some(ruler) => ruler.clear(generation)
+ None => ()
+ }
+}
+
+///|
+fn DiffEditorWidget::apply_overview_projection(
+ self : DiffEditorWidget,
+ projection : DiffFeatureProjection,
+ model : DiffEditorModelData,
+ generation : Int,
+) -> Unit {
+ match self.overview_ruler {
+ Some(ruler) =>
+ ruler.set_entries(
+ generation,
+ diff_overview_ruler_entries(projection, model.modified.get_line_count()),
+ )
+ None => ()
+ }
+}
+
+///|
+fn DiffEditorWidget::sync_overview_ruler_option(
+ self : DiffEditorWidget,
+) -> Unit {
+ if !self.options.overview_ruler {
+ match self.overview_ruler {
+ Some(ruler) => {
+ ruler.dispose()
+ self.overview_ruler_disposed = ruler.is_disposed()
+ }
+ None => ()
+ }
+ self.overview_ruler = None
+ return
+ }
+ if self.overview_ruler is None {
+ self.overview_ruler = Some(
+ self.editors.modified.create_overview_ruler(on_select=change_index => {
+ self.reveal_overview_change(change_index) |> ignore
+ }),
+ )
+ self.overview_ruler_disposed = false
+ }
+ match (self.projection, self.view_model.get_model()) {
+ (Some(projection), Some(model)) =>
+ self.apply_overview_projection(
+ projection,
+ model,
+ self.reconciling_generation,
+ )
+ _ => self.clear_overview_ruler(self.reconciling_generation)
+ }
+}
+
+///|
+fn DiffEditorWidget::commit_empty_diff_features(
+ self : DiffEditorWidget,
+ generation : Int,
+) -> Unit {
+ let original_viewport = self.editors.original.capture_stable_viewport_state()
+ let modified_viewport = if self.layout_state.actual_layout == Inline {
+ None
+ } else {
+ self.editors.modified.capture_stable_viewport_state()
+ }
+ let inline_modified_viewport = if self.layout_state.actual_layout == Inline {
+ self.editors.modified.save_view_state()
+ } else {
+ None
+ }
+ commit_diff_reconcile_generation(
+ generation,
+ () => self.clear_decorations(),
+ generation => {
+ self.original_alignment_zone_ids = replace_alignment_zones(
+ self.editors.original,
+ self.original_alignment_zone_ids,
+ [],
+ generation,
+ )
+ },
+ generation => {
+ self.dispose_inline_deleted_renderers()
+ self.modified_alignment_zone_ids = replace_alignment_zones(
+ self.editors.modified,
+ self.modified_alignment_zone_ids,
+ [],
+ generation,
+ )
+ },
+ () => {
+ self.restore_stable_viewports(
+ original_viewport, modified_viewport, inline_modified_viewport,
+ )
+ },
+ generation => self.clear_overview_ruler(generation),
+ )
+ self.original_alignment_geometry = None
+ self.modified_alignment_geometry = None
+ self.projection = None
+ self.live_region.set_inner_html("")
+}
+
+///|
+fn DiffEditorWidget::commit_projection(
+ self : DiffEditorWidget,
+ projection : DiffFeatureProjection,
+ model : DiffEditorModelData,
+ generation : Int,
+ preserve_inline_model_anchor? : Bool = false,
+) -> Unit {
+ let original_viewport = self.editors.original.capture_stable_viewport_state()
+ let preserve_inline_model_anchor = preserve_inline_model_anchor &&
+ self.layout_state.actual_layout == Inline
+ let modified_viewport = if self.layout_state.actual_layout == Inline &&
+ !preserve_inline_model_anchor {
+ None
+ } else {
+ self.editors.modified.capture_stable_viewport_state()
+ }
+ let inline_modified_viewport = if self.layout_state.actual_layout == Inline &&
+ !preserve_inline_model_anchor {
+ self.editors.modified.save_view_state()
+ } else {
+ None
+ }
+ let (original_geometry, modified_geometry) = self.capture_alignment_geometry()
+ let (
+ original_specs,
+ modified_specs,
+ next_inline_deleted_render_context,
+ next_inline_deleted_renderers,
+ ) = match
+ (self.layout_state.actual_layout, original_geometry, modified_geometry) {
+ (SideBySide, Some(original_geometry), Some(modified_geometry)) => {
+ let spacers = compute_managed_alignment_spacers(
+ projection, original_geometry, modified_geometry,
+ )
+ (
+ alignment_view_zone_specs(
+ spacers.original(),
+ "moonbit-diff-editor-original-alignment-spacer",
+ ),
+ alignment_view_zone_specs(
+ spacers.modified(),
+ "moonbit-diff-editor-modified-alignment-spacer",
+ ),
+ None,
+ [],
+ )
+ }
+ (Inline, _, _) => {
+ let inline_zones = inline_deleted_view_zone_specs(
+ self.editors.modified,
+ model.original,
+ projection,
+ generation,
+ )
+ (
+ [],
+ inline_zones.specs,
+ inline_zones.render_context,
+ inline_zones.virtualized_renderers,
+ )
+ }
+ _ => ([], [], None, [])
+ }
+ self.original_alignment_geometry = if self.layout_state.actual_layout ==
+ SideBySide {
+ original_geometry
+ } else {
+ None
+ }
+ self.modified_alignment_geometry = if self.layout_state.actual_layout ==
+ SideBySide {
+ modified_geometry
+ } else {
+ modified_geometry
+ }
+ commit_diff_reconcile_generation(
+ generation,
+ () => self.apply_projection_decorations(projection),
+ generation => {
+ self.original_alignment_zone_ids = replace_alignment_zones(
+ self.editors.original,
+ self.original_alignment_zone_ids,
+ original_specs,
+ generation,
+ )
+ },
+ generation => {
+ self.dispose_inline_deleted_renderers()
+ self.modified_alignment_zone_ids = replace_alignment_zones(
+ self.editors.modified,
+ self.modified_alignment_zone_ids,
+ modified_specs,
+ generation,
+ )
+ self.inline_deleted_render_context = next_inline_deleted_render_context
+ self.inline_deleted_renderers = next_inline_deleted_renderers
+ if self.inline_deleted_render_context is Some(_) {
+ self.inline_render_context_disposed = false
+ }
+ },
+ () => {
+ self.restore_stable_viewports(
+ original_viewport, modified_viewport, inline_modified_viewport,
+ )
+ },
+ generation => self.apply_overview_projection(projection, model, generation),
+ )
+ self.projection = Some(projection)
+}
+
+///|
+fn DiffEditorWidget::reconcile_geometry_if_changed(
+ self : DiffEditorWidget,
+) -> Unit {
+ if self.disposed ||
+ self.editors.is_committing_model_pair() ||
+ self.is_reconciling ||
+ self.projection is None ||
+ self.view_model.get_diff() is None {
+ return
+ }
+ let (original, modified) = self.capture_alignment_geometry()
+ let geometry_changed = match self.layout_state.actual_layout {
+ SideBySide =>
+ alignment_geometry_pair_changed(
+ original,
+ modified,
+ self.original_alignment_geometry,
+ self.modified_alignment_geometry,
+ )
+ Inline =>
+ !same_optional_alignment_geometry(
+ modified,
+ self.modified_alignment_geometry,
+ )
+ }
+ if !geometry_changed {
+ return
+ }
+ self.reconcile_view_model(geometry_refresh=true)
+}
+
+///|
+fn DiffEditorWidget::reconcile_view_model(
+ self : DiffEditorWidget,
+ geometry_refresh? : Bool = false,
+) -> Unit {
+ if self.disposed ||
+ self.editors.is_committing_model_pair() ||
+ self.is_reconciling {
+ return
+ }
+ if self.layout_render_remaining > 0 {
+ return
+ }
+ if !self.has_valid_layout_box || !self.host_has_positive_layout_box() {
+ self.has_valid_layout_box = false
+ return
+ }
+ let committed_model_generation = self.editors.committed_model_generation()
+ if !self.editors.same_model(self.view_model.get_model()) {
+ return
+ }
+ // Keep the pair commit as the outer boundary even if future projection
+ // construction gains a synchronous callback seam.
+ if self.editors.is_committing_model_pair() ||
+ self.editors.committed_model_generation() != committed_model_generation {
+ return
+ }
+ self.is_reconciling = true
+ defer {
+ self.is_reconciling = false
+ }
+ self.reconciling_generation += 1
+ let generation = self.reconciling_generation
+ self.navigation_index = None
+ guard self.view_model.get_diff() is Some(document_diff) &&
+ self.view_model.get_model() is Some(model) else {
+ self.commit_empty_diff_features(generation)
+ return
+ }
+ let projection = DiffFeatureProjection(
+ document_diff,
+ model.original.snapshot(),
+ model.modified.snapshot(),
+ )
+ self.commit_projection(
+ projection,
+ model,
+ generation,
+ preserve_inline_model_anchor=geometry_refresh &&
+ self.inline_deleted_render_context is Some(_),
+ )
+}
+
+///|
+fn DiffEditorWidget::clear_diff_features_for_transition(
+ self : DiffEditorWidget,
+) -> Unit {
+ if self.editors.is_committing_model_pair() ||
+ self.is_reconciling ||
+ (
+ self.projection is None &&
+ self.original_decoration_ids.is_empty() &&
+ self.modified_decoration_ids.is_empty() &&
+ self.original_alignment_zone_ids.is_empty() &&
+ self.modified_alignment_zone_ids.is_empty() &&
+ self.inline_deleted_renderers.is_empty()
+ ) {
+ return
+ }
+ self.is_reconciling = true
+ defer {
+ self.is_reconciling = false
+ }
+ self.reconciling_generation += 1
+ self.commit_empty_diff_features(self.reconciling_generation)
+}
+
+///|
+pub fn DiffEditorWidget::set_model(
+ self : DiffEditorWidget,
+ model : DiffEditorModelData?,
+) -> DiffEditorModelData? {
+ if self.disposed || same_model_pair(self.view_model.get_model(), model) {
+ return None
+ }
+ self.clear_diff_features_for_transition()
+ self.layout_editors_after_render()
+ // DiffEditorViewModel is the sole pair/ownership authority. Its synchronous
+ // model event mirrors the committed pair into both kernels before the diff
+ // update event clears or rebuilds feature artifacts.
+ let previous = self.view_model.set_model(model)
+ match model {
+ Some(_) if !self.editors.is_committing_model_pair() => {
+ self.editors.original.handle_initialized()
+ self.editors.modified.handle_initialized()
+ }
+ _ => ()
+ }
+ previous
+}
+
+///|
+pub fn DiffEditorWidget::get_model(
+ self : DiffEditorWidget,
+) -> DiffEditorModelData? {
+ self.view_model.get_model()
+}
+
+///|
+pub fn DiffEditorWidget::set_diff_provider(
+ self : DiffEditorWidget,
+ provider : &@diff.DocumentDiffProvider,
+) -> Unit {
+ self.view_model.set_provider(provider)
+}
+
+///|
+pub fn DiffEditorWidget::update_options(
+ self : DiffEditorWidget,
+ editor_options : @code_widget.CodeEditorOptions,
+ options : DiffEditorOptions,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.editor_options = editor_options
+ self.options = options
+ self.sync_overview_ruler_option()
+ let valid_layout_box = self.host_has_positive_layout_box()
+ if !valid_layout_box {
+ self.has_valid_layout_box = false
+ self.cancel_layout_render_barrier()
+ }
+ let original_had_focus = self.editors.original.has_text_focus()
+ let layout_changed = self.layout_state.update_options(
+ options,
+ recompute_layout=valid_layout_box,
+ )
+ if valid_layout_box {
+ self.has_valid_layout_box = true
+ self.apply_layout_dom()
+ if layout_changed &&
+ self.layout_state.actual_layout == Inline &&
+ original_had_focus {
+ self.editors.modified.focus()
+ }
+ self.editors.update_options(
+ editor_options,
+ options.diff_word_wrap,
+ self.layout_state.actual_layout,
+ )
+ self.layout_editors_after_render()
+ }
+ self.view_model.update_options({
+ ignore_trim_whitespace: options.ignore_trim_whitespace,
+ })
+}
+
+///|
+pub fn DiffEditorWidget::get_layout(
+ self : DiffEditorWidget,
+) -> DiffEditorLayout {
+ self.layout_state.actual_layout
+}
+
+///|
+pub fn DiffEditorWidget::get_diff(
+ self : DiffEditorWidget,
+) -> @diff.DocumentDiff? {
+ self.view_model.get_diff()
+}
+
+///|
+pub fn DiffEditorWidget::is_diff_up_to_date(self : DiffEditorWidget) -> Bool {
+ self.view_model.is_diff_up_to_date()
+}
+
+///|
+pub fn DiffEditorWidget::on_did_update_diff(
+ self : DiffEditorWidget,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.view_model.on_did_update_diff(() => {
+ if !self.disposed &&
+ !self.editors.is_committing_model_pair() &&
+ self.editors.same_model(self.view_model.get_model()) {
+ listener()
+ }
+ })
+}
+
+///|
+priv enum DiffNavigationPane {
+ OriginalNavigationPane
+ ModifiedNavigationPane
+}
+
+///|
+priv struct DiffNavigationRevealPlan {
+ pane : DiffNavigationPane
+ range : @base_common.Range
+}
+
+///|
+fn navigation_reveal_range(
+ range : @base_common.LineRange,
+ line_count : Int,
+) -> @base_common.Range {
+ if range.is_empty() {
+ // Empty sides are positions between physical lines. A code editor still
+ // needs a valid model coordinate, so represent the position as a
+ // collapsed range on the nearest modified/original line.
+ let anchor = range.start_line_number.clamp(min=1, max=line_count.max(1))
+ return Range(anchor, 1, anchor, 1)
+ }
+ Range(range.start_line_number, 1, range.end_line_number_exclusive - 1, 1)
+}
+
+///|
+fn diff_navigation_reveal_plan(
+ layout : DiffEditorLayout,
+ change : @diff.DetailedLineRangeMapping,
+ original_line_count : Int,
+ modified_line_count : Int,
+) -> DiffNavigationRevealPlan? {
+ match layout {
+ Inline =>
+ // Inline has only one interactive code surface. In particular, a
+ // deletion-only hunk must reveal its empty modified-side anchor instead
+ // of moving focus into the hidden original kernel.
+ Some({
+ pane: ModifiedNavigationPane,
+ range: navigation_reveal_range(change.modified, modified_line_count),
+ })
+ SideBySide =>
+ if !change.modified.is_empty() {
+ Some({
+ pane: ModifiedNavigationPane,
+ range: navigation_reveal_range(change.modified, modified_line_count),
+ })
+ } else if !change.original.is_empty() {
+ Some({
+ pane: OriginalNavigationPane,
+ range: navigation_reveal_range(change.original, original_line_count),
+ })
+ } else {
+ None
+ }
+ }
+}
+
+///|
+fn navigation_range_announcement(
+ side : String,
+ empty_kind : String,
+ range : @base_common.LineRange,
+) -> String {
+ if range.is_empty() {
+ "\{side} \{empty_kind} anchor at line \{range.start_line_number}"
+ } else {
+ "\{side} lines \{range.start_line_number} through " +
+ "\{range.end_line_number_exclusive - 1}"
+ }
+}
+
+///|
+fn diff_navigation_announcement(target : DiffNavigationTarget) -> String {
+ let change = target.change
+ "Change \{target.change_index + 1} of \{target.change_count}; " +
+ navigation_range_announcement("original", "insertion", change.original) +
+ "; " +
+ navigation_range_announcement("modified", "deletion", change.modified)
+}
+
+///|
+fn DiffEditorWidget::announce_navigation_target(
+ self : DiffEditorWidget,
+ target : DiffNavigationTarget,
+) -> Unit {
+ self.live_region.set_inner_html(diff_navigation_announcement(target))
+}
+
+///|
+fn DiffEditorWidget::reveal_inline_deleted_zone(
+ self : DiffEditorWidget,
+ change_index : Int,
+) -> Bool {
+ if self.layout_state.actual_layout != Inline {
+ return false
+ }
+ for entry in self.inline_deleted_renderers {
+ if entry.change_index == change_index && entry.renderer.reveal_zone_top() {
+ self.editors.modified.focus()
+ return true
+ }
+ }
+ false
+}
+
+///|
+fn DiffEditorWidget::reveal_navigation_target(
+ self : DiffEditorWidget,
+ target : DiffNavigationTarget,
+) -> Bool {
+ let change = target.change
+ guard self.view_model.get_model() is Some(model) else { return false }
+ self.navigation_index = Some(target.change_index)
+ if change.modified.is_empty() &&
+ !change.original.is_empty() &&
+ self.reveal_inline_deleted_zone(target.change_index) {
+ self.announce_navigation_target(target)
+ return true
+ }
+ guard diff_navigation_reveal_plan(
+ self.layout_state.actual_layout,
+ change,
+ model.original.get_line_count(),
+ model.modified.get_line_count(),
+ )
+ is Some(plan) else {
+ return false
+ }
+ match plan.pane {
+ OriginalNavigationPane => {
+ self.editors.original.reveal_range_in_center(plan.range)
+ self.editors.original.focus()
+ }
+ ModifiedNavigationPane => {
+ self.editors.modified.reveal_range_in_center(plan.range)
+ self.editors.modified.focus()
+ }
+ }
+ self.announce_navigation_target(target)
+ true
+}
+
+///|
+fn DiffEditorWidget::reveal_overview_change(
+ self : DiffEditorWidget,
+ change_index : Int,
+) -> Bool {
+ guard !self.disposed &&
+ self.projection is Some(projection) &&
+ projection.change_at(change_index) is Some(change) &&
+ self.view_model.get_model() is Some(model) else {
+ return false
+ }
+ self.navigation_index = Some(change_index)
+ if change.modified.is_empty() &&
+ !change.original.is_empty() &&
+ self.reveal_inline_deleted_zone(change_index) {
+ self.announce_navigation_target({
+ change_index,
+ change_count: projection.change_count(),
+ change,
+ })
+ return true
+ }
+ if !change.modified.is_empty() {
+ self.editors.modified.reveal_range_in_center(
+ Range(
+ change.modified.start_line_number,
+ 1,
+ change.modified.end_line_number_exclusive - 1,
+ 1,
+ ),
+ )
+ } else {
+ let line_number = change.modified.start_line_number.clamp(
+ min=1,
+ max=model.modified.get_line_count().max(1),
+ )
+ self.editors.modified.reveal_line_in_center(line_number)
+ }
+ self.editors.modified.focus()
+ self.announce_navigation_target({
+ change_index,
+ change_count: projection.change_count(),
+ change,
+ })
+ true
+}
+
+///|
+pub fn DiffEditorWidget::reveal_next_change(self : DiffEditorWidget) -> Bool {
+ guard self.projection is Some(projection) &&
+ projection.next_change(self.navigation_index) is Some(target) else {
+ return false
+ }
+ self.reveal_navigation_target(target)
+}
+
+///|
+pub fn DiffEditorWidget::reveal_previous_change(
+ self : DiffEditorWidget,
+) -> Bool {
+ guard self.projection is Some(projection) &&
+ projection.previous_change(self.navigation_index) is Some(target) else {
+ return false
+ }
+ self.reveal_navigation_target(target)
+}
+
+///|
+fn DiffEditorWidget::apply_layout_dom(self : DiffEditorWidget) -> Unit {
+ let ratio = self.layout_state.split_ratio
+ self.sash.set_attribute("aria-valuenow", (ratio * 100.0).round().to_string())
+ match self.layout_state.actual_layout {
+ SideBySide => {
+ self.root.set_attribute(
+ "class", "moonbit-diff-editor moonbit-diff-editor-side-by-side",
+ )
+ self.root.set_attribute("data-render-mode", "side-by-side")
+ self.original_host.remove_attribute("hidden")
+ self.original_host.remove_attribute("aria-hidden")
+ if self.options.sash_enabled {
+ self.sash.remove_attribute("hidden")
+ self.sash.remove_attribute("aria-hidden")
+ self.sash.set_attribute("tabindex", "0")
+ } else {
+ self.sash.set_attribute("hidden", "")
+ self.sash.set_attribute("aria-hidden", "true")
+ self.sash.remove_attribute("tabindex")
+ }
+ self.modified_host.set_attribute("aria-label", "Modified file")
+ self.original_host.set_attribute(
+ "style",
+ "flex-basis: \{ratio * 100.0}%;",
+ )
+ self.modified_host.set_attribute(
+ "style",
+ "flex-basis: \{(1.0 - ratio) * 100.0}%;",
+ )
+ }
+ Inline => {
+ self.root.set_attribute(
+ "class", "moonbit-diff-editor moonbit-diff-editor-inline",
+ )
+ self.root.set_attribute("data-render-mode", "inline")
+ self.original_host.set_attribute("hidden", "")
+ self.original_host.set_attribute("aria-hidden", "true")
+ self.sash.set_attribute("hidden", "")
+ self.sash.set_attribute("aria-hidden", "true")
+ self.sash.remove_attribute("tabindex")
+ self.modified_host.set_attribute("aria-label", "Inline diff")
+ self.original_host.remove_attribute("style")
+ self.modified_host.remove_attribute("style")
+ }
+ }
+}
+
+///|
+pub fn DiffEditorWidget::layout(self : DiffEditorWidget) -> Unit {
+ if self.disposed {
+ return
+ }
+ let rect = self.host.get_bounding_client_rect()
+ let width = rect.get_width()
+ let height = rect.get_height()
+ if width <= 0.0 || height <= 0.0 {
+ self.has_valid_layout_box = false
+ self.cancel_layout_render_barrier()
+ return
+ }
+ self.has_valid_layout_box = true
+ let original_had_focus = self.editors.original.has_text_focus()
+ let layout_changed = self.layout_state.layout(width)
+ self.apply_layout_dom()
+ if layout_changed &&
+ self.layout_state.actual_layout == Inline &&
+ original_had_focus {
+ self.editors.modified.focus()
+ }
+ self.editors.update_options(
+ self.editor_options,
+ self.options.diff_word_wrap,
+ self.layout_state.actual_layout,
+ )
+ self.layout_editors_after_render()
+}
+
+///|
+pub fn DiffEditorWidget::focus(self : DiffEditorWidget) -> Unit {
+ self.editors.focus()
+}
+
+///|
+pub fn DiffEditorWidget::dispose(self : DiffEditorWidget) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.dispose_pass_count += 1
+ self.clear_diff_features_for_transition()
+ self.disposed = true
+ match self.resize_observer {
+ Some(observer) => {
+ observer.dispose()
+ self.resize_observer_disposed = true
+ }
+ None => ()
+ }
+ self.resize_observer = None
+ self.pointer_monitor.dispose()
+ self.cancel_layout_render_barrier()
+ for disposable in self.lifetime {
+ disposable.dispose()
+ }
+ self.lifetime.clear()
+ self.view_model.dispose()
+ match self.overview_ruler {
+ Some(ruler) => {
+ ruler.dispose()
+ self.overview_ruler_disposed = ruler.is_disposed()
+ }
+ None => ()
+ }
+ self.overview_ruler = None
+ self.editors.dispose()
+ self.original_decoration_ids.clear()
+ self.modified_decoration_ids.clear()
+ self.original_alignment_zone_ids.clear()
+ self.modified_alignment_zone_ids.clear()
+ self.dispose_inline_deleted_renderers()
+ self.original_alignment_geometry = None
+ self.modified_alignment_geometry = None
+ self.projection = None
+ self.navigation_index = None
+ self.reconciling_generation = 0
+ self.is_reconciling = false
+ self.layout_render_generation = 0
+ self.has_valid_layout_box = false
+ self.host.remove_child(self.root.as_node())
+}
diff --git a/editor/internal/viewer/diff_editor/widget_navigation_wbtest.mbt b/editor/internal/viewer/diff_editor/widget_navigation_wbtest.mbt
new file mode 100644
index 000000000..b13a10a00
--- /dev/null
+++ b/editor/internal/viewer/diff_editor/widget_navigation_wbtest.mbt
@@ -0,0 +1,83 @@
+///|
+fn widget_navigation_test_change(
+ original_start : Int,
+ original_end : Int,
+ modified_start : Int,
+ modified_end : Int,
+) -> @diff.DetailedLineRangeMapping {
+ DetailedLineRangeMapping(
+ LineRange(original_start, original_end),
+ LineRange(modified_start, modified_end),
+ None,
+ )
+}
+
+///|
+test "Inline navigation always uses the modified pane and clamps deletion anchors" {
+ let deletion = widget_navigation_test_change(6, 7, 6, 6)
+ let plan = diff_navigation_reveal_plan(Inline, deletion, 7, 5).unwrap()
+ assert_true(plan.pane is ModifiedNavigationPane)
+ assert_eq(plan.range, Range(5, 1, 5, 1))
+ assert_true(plan.range.is_empty())
+
+ let empty_document_plan = diff_navigation_reveal_plan(
+ Inline,
+ widget_navigation_test_change(1, 2, 1, 1),
+ 1,
+ 0,
+ ).unwrap()
+ assert_true(empty_document_plan.pane is ModifiedNavigationPane)
+ assert_eq(empty_document_plan.range, Range(1, 1, 1, 1))
+}
+
+///|
+test "side-by-side navigation chooses the non-empty pane" {
+ let deletion = diff_navigation_reveal_plan(
+ SideBySide,
+ widget_navigation_test_change(2, 3, 2, 2),
+ 4,
+ 3,
+ ).unwrap()
+ assert_true(deletion.pane is OriginalNavigationPane)
+ assert_eq(deletion.range, Range(2, 1, 2, 1))
+
+ let insertion = diff_navigation_reveal_plan(
+ SideBySide,
+ widget_navigation_test_change(3, 3, 3, 4),
+ 3,
+ 4,
+ ).unwrap()
+ assert_true(insertion.pane is ModifiedNavigationPane)
+ assert_eq(insertion.range, Range(3, 1, 3, 1))
+
+ let modification = diff_navigation_reveal_plan(
+ SideBySide,
+ widget_navigation_test_change(4, 5, 4, 5),
+ 5,
+ 5,
+ ).unwrap()
+ assert_true(modification.pane is ModifiedNavigationPane)
+}
+
+///|
+test "navigation announcements name insertion and deletion anchors" {
+ let deletion = widget_navigation_test_change(6, 7, 6, 6)
+ assert_eq(
+ diff_navigation_announcement({
+ change_index: 0,
+ change_count: 2,
+ change: deletion,
+ }),
+ "Change 1 of 2; original lines 6 through 6; modified deletion anchor at line 6",
+ )
+
+ let insertion = widget_navigation_test_change(3, 3, 3, 4)
+ assert_eq(
+ diff_navigation_announcement({
+ change_index: 1,
+ change_count: 2,
+ change: insertion,
+ }),
+ "Change 2 of 2; original insertion anchor at line 3; modified lines 3 through 3",
+ )
+}
diff --git a/editor/internal/viewer/markdown_viewer/markdown_viewer.mbt b/editor/internal/viewer/markdown_viewer/markdown_viewer.mbt
new file mode 100644
index 000000000..e322779f3
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/markdown_viewer.mbt
@@ -0,0 +1,790 @@
+///|
+/// The kind of Markdown resource being rendered. MoonBit Markdown enables the
+/// semantic `mbt check`/`moonbit check` fence projection; ordinary Markdown
+/// keeps every fence presentation-only. Selection is explicit and never
+/// inferred from the model URI or language id.
+pub(all) enum MarkdownResourceKind {
+ OrdinaryMarkdown
+ MoonBitMarkdown
+} derive(Eq, Debug)
+
+///|
+/// One caller-owned source model together with its explicit Markdown resource
+/// semantics. MarkdownViewerWidget owns neither value and never disposes the model.
+pub(all) struct MarkdownViewerWidgetModel {
+ model : @model.TextModel
+ resource_kind : MarkdownResourceKind
+} derive(Debug)
+
+///|
+pub fn MarkdownViewerWidgetModel::MarkdownViewerWidgetModel(
+ model : @model.TextModel,
+ resource_kind : MarkdownResourceKind,
+) -> MarkdownViewerWidgetModel {
+ { model, resource_kind }
+}
+
+///|
+fn MarkdownViewerWidgetModel::same(
+ self : MarkdownViewerWidgetModel,
+ other : MarkdownViewerWidgetModel,
+) -> Bool {
+ self.model.same(other.model) && self.resource_kind == other.resource_kind
+}
+
+///|
+fn MarkdownResourceKind::to_internal(
+ self : MarkdownResourceKind,
+) -> @markdown_core.MarkdownResourceKind {
+ match self {
+ OrdinaryMarkdown => OrdinaryMarkdown
+ MoonBitMarkdown => MoonBitMarkdown
+ }
+}
+
+///|
+/// Runtime options owned by the Markdown surface. Code-editor layout and input
+/// options deliberately do not cross this API.
+struct MarkdownViewerWidgetOptions {
+ theme : String
+ placeholder : String
+ show_unused : Bool
+} derive(Eq, Debug)
+
+///|
+pub fn MarkdownViewerWidgetOptions::MarkdownViewerWidgetOptions(
+ theme? : String = "dark",
+ placeholder? : String = "",
+ show_unused? : Bool = true,
+) -> MarkdownViewerWidgetOptions {
+ { theme, placeholder, show_unused }
+}
+
+///|
+pub fn MarkdownViewerWidgetOptions::default() -> MarkdownViewerWidgetOptions {
+ MarkdownViewerWidgetOptions()
+}
+
+///|
+pub fn MarkdownViewerWidgetOptions::theme(
+ self : MarkdownViewerWidgetOptions,
+) -> String {
+ self.theme
+}
+
+///|
+pub fn MarkdownViewerWidgetOptions::placeholder(
+ self : MarkdownViewerWidgetOptions,
+) -> String {
+ self.placeholder
+}
+
+///|
+pub fn MarkdownViewerWidgetOptions::show_unused(
+ self : MarkdownViewerWidgetOptions,
+) -> Bool {
+ self.show_unused
+}
+
+///|
+pub fn MarkdownViewerWidgetOptions::with_theme(
+ self : MarkdownViewerWidgetOptions,
+ theme : String,
+) -> MarkdownViewerWidgetOptions {
+ { ..self, theme, }
+}
+
+///|
+pub fn MarkdownViewerWidgetOptions::with_placeholder(
+ self : MarkdownViewerWidgetOptions,
+ placeholder : String,
+) -> MarkdownViewerWidgetOptions {
+ { ..self, placeholder, }
+}
+
+///|
+pub fn MarkdownViewerWidgetOptions::with_show_unused(
+ self : MarkdownViewerWidgetOptions,
+ show_unused : Bool,
+) -> MarkdownViewerWidgetOptions {
+ { ..self, show_unused, }
+}
+
+///|
+/// Host-restorable native Markdown viewport state.
+struct MarkdownViewerWidgetViewState {
+ scroll_top : Double
+ scroll_left : Double
+} derive(Eq, Debug)
+
+///|
+priv struct MarkdownViewerWidgetIdCounter {
+ mut value : Int
+}
+
+///|
+let markdown_viewer_id_counter : MarkdownViewerWidgetIdCounter = { value: 0 }
+
+///|
+fn next_markdown_viewer_id() -> Int {
+ markdown_viewer_id_counter.value += 1
+ markdown_viewer_id_counter.value
+}
+
+///|
+priv struct MarkdownViewerWidgetPlaceholder {
+ root : @rdom.Element
+ text : @rdom.Element
+}
+
+///|
+fn MarkdownViewerWidgetPlaceholder::MarkdownViewerWidgetPlaceholder(
+ options : MarkdownViewerWidgetOptions,
+) -> MarkdownViewerWidgetPlaceholder {
+ let document = @rdom.document()
+ let root = document.create_element("section")
+ root.set_attribute("class", "moonbit-viewer-markdown-empty")
+ root.set_attribute("data-theme", options.theme)
+ let message = document.create_element("div")
+ message.set_attribute("class", "source-message")
+ let text = document.create_text_node(options.placeholder)
+ message.append_child(text.as_node())
+ root.append_child(message.as_node())
+ { root, text }
+}
+
+///|
+fn MarkdownViewerWidgetPlaceholder::update(
+ self : MarkdownViewerWidgetPlaceholder,
+ options : MarkdownViewerWidgetOptions,
+) -> Unit {
+ self.root.set_attribute("data-theme", options.theme)
+ self.text.set_node_value(@js.Nullable::from_option(Some(options.placeholder)))
+}
+
+///|
+/// All resources whose lifetime is exactly one installed Markdown model.
+priv struct MarkdownViewerWidgetAttachment {
+ model : MarkdownViewerWidgetModel
+ generation : Int
+ view : @markdown_document.MarkdownDocumentView?
+ hover_bridge : @hover_browser.MarkdownDocumentHoverBridge?
+ definition_bridge : MarkdownViewerWidgetDefinitionBridge?
+ fold : MarkdownViewerWidgetFoldState
+ listeners : Array[@base_common.Disposable]
+ mut disposed : Bool
+}
+
+///|
+/// A first-class readonly Markdown surface. It directly owns one
+/// MarkdownDocumentView and its model-scoped feature bridges; it does not
+/// create, contain, or expose Viewer/CodeEditorWidget.
+struct MarkdownViewerWidget {
+ services : MarkdownViewerServices
+ owns_services : Bool
+ id : Int
+ mut options : MarkdownViewerWidgetOptions
+ mut host : @rdom.Element?
+ mut placeholder : MarkdownViewerWidgetPlaceholder?
+ mut resize_observer : @base_common.Disposable?
+ mut generation : Int
+ mut current : MarkdownViewerWidgetAttachment?
+ mut focused : Bool
+ mut disposed : Bool
+ did_change_model : @base_common.Emitter[@editor_api.ModelChangedEvent]
+ did_change_model_content : @base_common.Emitter[
+ @model.ModelContentChangedEvent,
+ ]
+ did_scroll : @base_common.Emitter[Unit]
+ did_change_content_size : @base_common.Emitter[Unit]
+ did_dispose : @base_common.Emitter[Unit]
+ did_detach_for_diagnostics : @base_common.Emitter[Unit]
+}
+
+///|
+/// Package-private headless constructor used by deterministic lifecycle tests.
+fn MarkdownViewerWidget::MarkdownViewerWidget(
+ services? : MarkdownViewerServices,
+ options? : MarkdownViewerWidgetOptions = MarkdownViewerWidgetOptions::default(),
+) -> MarkdownViewerWidget {
+ let (services, owns_services) = match services {
+ Some(services) => (services, false)
+ None => (MarkdownViewerServices(), true)
+ }
+ {
+ services,
+ owns_services,
+ id: next_markdown_viewer_id(),
+ options,
+ host: None,
+ placeholder: None,
+ resize_observer: None,
+ generation: 0,
+ current: None,
+ focused: false,
+ disposed: false,
+ did_change_model: Emitter(),
+ did_change_model_content: Emitter(),
+ did_scroll: Emitter(),
+ did_change_content_size: Emitter(),
+ did_dispose: Emitter(),
+ did_detach_for_diagnostics: Emitter(),
+ }
+}
+
+///|
+/// Creates a mounted Markdown viewer. The caller owns `host` and must keep it
+/// stable and otherwise child-free for the viewer lifetime.
+pub fn MarkdownViewerWidget::create(
+ host : @rdom.Element,
+ services? : MarkdownViewerServices,
+ options? : MarkdownViewerWidgetOptions = MarkdownViewerWidgetOptions::default(),
+) -> MarkdownViewerWidget {
+ let viewer = MarkdownViewerWidget(services?, options~)
+ viewer.mount(host)
+ viewer
+}
+
+///|
+fn MarkdownViewerWidget::mount(
+ self : MarkdownViewerWidget,
+ host : @rdom.Element,
+) -> Unit {
+ guard !self.disposed && self.host is None else { return }
+ let placeholder = MarkdownViewerWidgetPlaceholder(self.options)
+ self.host = Some(host)
+ self.placeholder = Some(placeholder)
+ host.append_child(placeholder.root.as_node())
+ self.resize_observer = @base_browser.observe_element_resize(host, () => {
+ if !self.disposed {
+ self.layout()
+ }
+ })
+ self.layout()
+}
+
+///|
+fn MarkdownViewerWidget::is_current_attachment(
+ self : MarkdownViewerWidget,
+ model : @model.TextModel,
+ generation : Int,
+) -> Bool {
+ guard !self.disposed && self.generation == generation else { return false }
+ match self.current {
+ Some(current) =>
+ current.generation == generation && current.model.model.same(model)
+ None => false
+ }
+}
+
+///|
+fn MarkdownViewerWidget::attach_model(
+ self : MarkdownViewerWidget,
+ model : MarkdownViewerWidgetModel,
+) -> Unit {
+ self.generation += 1
+ let generation = self.generation
+ let listeners : Array[@base_common.Disposable] = []
+ listeners.push(self.services.marker_decorations().acquire_model(model.model))
+ listeners.push(
+ model.model.on_did_change_content(event => {
+ guard self.is_current_attachment(model.model, generation) &&
+ self.current is Some(current) else {
+ return
+ }
+ current.replace_source(model.model.get_value(), event.version_id)
+ self.did_change_model_content.fire(event)
+ self.did_change_content_size.fire(())
+ }),
+ )
+ listeners.push(
+ model.model.on_will_dispose(() => {
+ if self.is_current_attachment(model.model, generation) {
+ self.set_model(None) |> ignore
+ }
+ }),
+ )
+ let fold = MarkdownViewerWidgetFoldState()
+ let (view, hover_bridge, definition_bridge) = match self.host {
+ None => (None, None, None)
+ Some(host) => {
+ let view = @markdown_document.MarkdownDocumentView(
+ model.model.get_value(),
+ model.model.get_version_id(),
+ model.model.get_language_id(),
+ model.model.uri,
+ model.resource_kind.to_internal(),
+ self.options.theme,
+ () => {
+ if self.is_current_attachment(model.model, generation) {
+ self.did_scroll.fire(())
+ }
+ },
+ focused => {
+ if self.is_current_attachment(model.model, generation) {
+ self.focused = focused
+ }
+ },
+ () => {
+ if self.is_current_attachment(model.model, generation) {
+ self.did_change_content_size.fire(())
+ }
+ },
+ show_unused=self.options.show_unused,
+ )
+ self.hide_placeholder()
+ host.append_child(view.root().as_node())
+ let computer = @hover.ContentHoverComputer({
+ languages: self.services.languages(),
+ marker_decorations: self.services.marker_decorations(),
+ })
+ let hover_bridge = @hover_browser.MarkdownDocumentHoverBridge(
+ view,
+ model.model,
+ computer,
+ self.services.marker_decorations(),
+ generation,
+ () => self.is_current_attachment(model.model, generation),
+ task => self.services.launch_async_task(task),
+ )
+ let definition_bridge = MarkdownViewerWidgetDefinitionBridge(
+ self,
+ view,
+ model.model,
+ generation,
+ )
+ (Some(view), Some(hover_bridge), Some(definition_bridge))
+ }
+ }
+ let attachment : MarkdownViewerWidgetAttachment = {
+ model,
+ generation,
+ view,
+ hover_bridge,
+ definition_bridge,
+ fold,
+ listeners,
+ disposed: false,
+ }
+ self.current = Some(attachment)
+ attachment.initialize_folding()
+ match hover_bridge {
+ Some(bridge) => bridge.activate()
+ None => ()
+ }
+ match definition_bridge {
+ Some(bridge) => bridge.activate()
+ None => ()
+ }
+ self.layout()
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::replace_source(
+ self : MarkdownViewerWidgetAttachment,
+ source : String,
+ source_model_version : Int,
+) -> Unit {
+ guard !self.disposed && self.view is Some(view) else { return }
+ match self.definition_bridge {
+ Some(bridge) => bridge.before_projection_replaced()
+ None => ()
+ }
+ match self.hover_bridge {
+ Some(bridge) => bridge.before_projection_replaced()
+ None => ()
+ }
+ view.replace_source(source, source_model_version)
+ self.reconcile_folding(source)
+ match self.hover_bridge {
+ Some(bridge) => bridge.after_projection_replaced()
+ None => ()
+ }
+ match self.definition_bridge {
+ Some(bridge) => bridge.after_projection_replaced()
+ None => ()
+ }
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::apply_theme(
+ self : MarkdownViewerWidgetAttachment,
+ theme : String,
+) -> Unit {
+ guard !self.disposed && self.view is Some(view) else { return }
+ match self.definition_bridge {
+ Some(bridge) => bridge.before_projection_replaced()
+ None => ()
+ }
+ match self.hover_bridge {
+ Some(bridge) => bridge.before_projection_replaced()
+ None => ()
+ }
+ view.apply_theme(theme)
+ self.reconcile_folding(self.fold.source)
+ match self.hover_bridge {
+ Some(bridge) => bridge.after_projection_replaced()
+ None => ()
+ }
+ match self.definition_bridge {
+ Some(bridge) => bridge.after_projection_replaced()
+ None => ()
+ }
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::layout_changed(
+ self : MarkdownViewerWidgetAttachment,
+) -> Unit {
+ match self.hover_bridge {
+ Some(bridge) => bridge.layout_changed()
+ None => ()
+ }
+ match self.definition_bridge {
+ Some(bridge) => bridge.layout_changed()
+ None => ()
+ }
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::dispose(
+ self : MarkdownViewerWidgetAttachment,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ match self.definition_bridge {
+ Some(bridge) => bridge.dispose()
+ None => ()
+ }
+ match self.hover_bridge {
+ Some(bridge) => bridge.dispose()
+ None => ()
+ }
+ match self.view {
+ Some(view) => view.dispose()
+ None => ()
+ }
+ while self.listeners.length() > 0 {
+ self.listeners.remove(self.listeners.length() - 1).dispose()
+ }
+}
+
+///|
+fn MarkdownViewerWidget::detach_model(
+ self : MarkdownViewerWidget,
+) -> MarkdownViewerWidgetModel? {
+ guard self.current is Some(current) else { return None }
+ self.generation += 1
+ self.current = None
+ self.focused = false
+ let root = match current.view {
+ Some(view) => Some(view.root())
+ None => None
+ }
+ current.dispose()
+ match root {
+ Some(root) => @base_browser.remove_element(root)
+ None => ()
+ }
+ if !self.disposed {
+ self.restore_placeholder()
+ }
+ self.did_detach_for_diagnostics.fire(())
+ Some(current.model)
+}
+
+///|
+fn MarkdownViewerWidget::hide_placeholder(self : MarkdownViewerWidget) -> Unit {
+ match self.placeholder {
+ Some(placeholder) => @base_browser.remove_element(placeholder.root)
+ None => ()
+ }
+}
+
+///|
+fn MarkdownViewerWidget::restore_placeholder(
+ self : MarkdownViewerWidget,
+) -> Unit {
+ match (self.host, self.placeholder) {
+ (Some(host), Some(placeholder)) =>
+ if !@base_browser.element_contains(host, placeholder.root) {
+ host.append_child(placeholder.root.as_node())
+ }
+ _ => ()
+ }
+}
+
+///|
+/// Atomically swaps the explicit Markdown model. The returned previous model is
+/// already fully detached: all browser bridges, model listeners, marker lease,
+/// and presentation DOM have been retired before this call returns.
+pub fn MarkdownViewerWidget::set_model(
+ self : MarkdownViewerWidget,
+ model : MarkdownViewerWidgetModel?,
+) -> MarkdownViewerWidgetModel? {
+ if self.disposed {
+ return None
+ }
+ match (self.current, model) {
+ (None, None) => return None
+ (Some(current), Some(next)) if current.model.same(next) => return None
+ _ => ()
+ }
+ let old_model_url = match self.current {
+ Some(current) => Some(current.model.model.uri)
+ None => None
+ }
+ let new_model_url = match model {
+ Some(next) => Some(next.model.uri)
+ None => None
+ }
+ let old = self.detach_model()
+ match model {
+ Some(next) => self.attach_model(next)
+ None => ()
+ }
+ self.did_change_model.fire({ old_model_url, new_model_url })
+ old
+}
+
+///|
+pub fn MarkdownViewerWidget::get_model(
+ self : MarkdownViewerWidget,
+) -> MarkdownViewerWidgetModel? {
+ match self.current {
+ Some(current) => Some(current.model)
+ None => None
+ }
+}
+
+///|
+pub fn MarkdownViewerWidget::on_did_change_model(
+ self : MarkdownViewerWidget,
+ listener : (@editor_api.ModelChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.did_change_model.event(listener)
+}
+
+///|
+pub fn MarkdownViewerWidget::on_did_change_model_content(
+ self : MarkdownViewerWidget,
+ listener : (@model.ModelContentChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.did_change_model_content.event(listener)
+}
+
+///|
+pub fn MarkdownViewerWidget::on_did_scroll(
+ self : MarkdownViewerWidget,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.did_scroll.event(_ => listener())
+}
+
+///|
+pub fn MarkdownViewerWidget::on_did_change_content_size(
+ self : MarkdownViewerWidget,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.did_change_content_size.event(_ => listener())
+}
+
+///|
+pub fn MarkdownViewerWidget::on_did_dispose(
+ self : MarkdownViewerWidget,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.did_dispose.event(_ => listener())
+}
+
+///|
+fn MarkdownViewerWidget::on_did_detach_for_diagnostics(
+ self : MarkdownViewerWidget,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.did_detach_for_diagnostics.event(_ => listener())
+}
+
+///|
+let _markdown_viewer_widget_test_seam : Unit = ignore(
+ MarkdownViewerWidget::on_did_detach_for_diagnostics,
+)
+
+///|
+pub fn MarkdownViewerWidget::get_options(
+ self : MarkdownViewerWidget,
+) -> MarkdownViewerWidgetOptions {
+ self.options
+}
+
+///|
+pub fn MarkdownViewerWidget::update_options(
+ self : MarkdownViewerWidget,
+ options : MarkdownViewerWidgetOptions,
+) -> Unit {
+ if self.disposed || self.options == options {
+ return
+ }
+ let previous = self.options
+ self.options = options
+ match self.placeholder {
+ Some(placeholder) => placeholder.update(options)
+ None => ()
+ }
+ match self.current {
+ Some(current) => {
+ if previous.theme != options.theme {
+ current.apply_theme(options.theme)
+ }
+ if previous.show_unused != options.show_unused {
+ match current.view {
+ Some(view) => view.apply_marker_unused_visibility(options.show_unused)
+ None => ()
+ }
+ }
+ }
+ None => ()
+ }
+}
+
+///|
+pub fn MarkdownViewerWidget::get_id(self : MarkdownViewerWidget) -> String {
+ "markdown.viewer:\{self.id}"
+}
+
+///|
+pub fn MarkdownViewerWidget::get_dom_node(
+ self : MarkdownViewerWidget,
+) -> @rdom.Element? {
+ guard self.current is Some(current) && current.view is Some(view) else {
+ return None
+ }
+ Some(view.root())
+}
+
+///|
+pub fn MarkdownViewerWidget::get_container_dom_node(
+ self : MarkdownViewerWidget,
+) -> @rdom.Element {
+ match self.host {
+ Some(host) => host
+ None => abort("MarkdownViewerWidget is not attached")
+ }
+}
+
+///|
+pub fn MarkdownViewerWidget::focus(self : MarkdownViewerWidget) -> Unit {
+ match self.get_dom_node() {
+ Some(root) => @base_browser.focus_prevent_scroll(root)
+ None => ()
+ }
+}
+
+///|
+pub fn MarkdownViewerWidget::has_text_focus(
+ self : MarkdownViewerWidget,
+) -> Bool {
+ self.focused
+}
+
+///|
+pub fn MarkdownViewerWidget::layout(self : MarkdownViewerWidget) -> Unit {
+ guard !self.disposed &&
+ self.host is Some(host) &&
+ self.current is Some(current) &&
+ current.view is Some(view) else {
+ return
+ }
+ view.layout(
+ @base_browser.element_client_width(host),
+ @base_browser.element_client_height(host),
+ )
+ current.layout_changed()
+}
+
+///|
+pub fn MarkdownViewerWidget::save_view_state(
+ self : MarkdownViewerWidget,
+) -> MarkdownViewerWidgetViewState? {
+ guard self.current is Some(current) && current.view is Some(view) else {
+ return None
+ }
+ Some({ scroll_top: view.scroll_top(), scroll_left: view.scroll_left() })
+}
+
+///|
+pub fn MarkdownViewerWidget::restore_view_state(
+ self : MarkdownViewerWidget,
+ state : MarkdownViewerWidgetViewState?,
+) -> Unit {
+ guard state is Some(state) &&
+ self.current is Some(current) &&
+ current.view is Some(view) else {
+ return
+ }
+ view.set_scroll_position(
+ scroll_top=state.scroll_top,
+ scroll_left=state.scroll_left,
+ )
+ current.layout_changed()
+}
+
+///|
+pub fn MarkdownViewerWidget::reveal_source_offset(
+ self : MarkdownViewerWidget,
+ source_offset : Int,
+) -> Unit {
+ guard self.current is Some(current) && current.view is Some(view) else {
+ return
+ }
+ view.reveal_source_offset(source_offset, "center")
+ current.layout_changed()
+}
+
+///|
+pub fn MarkdownViewerWidget::get_content_height(
+ self : MarkdownViewerWidget,
+) -> Double {
+ guard self.current is Some(current) && current.view is Some(view) else {
+ return 0.0
+ }
+ view.content_height()
+}
+
+///|
+pub fn MarkdownViewerWidget::get_content_width(
+ self : MarkdownViewerWidget,
+) -> Double {
+ guard self.current is Some(current) && current.view is Some(view) else {
+ return 0.0
+ }
+ view.content_width()
+}
+
+///|
+pub fn MarkdownViewerWidget::dispose(self : MarkdownViewerWidget) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ match self.resize_observer {
+ Some(observer) => observer.dispose()
+ None => ()
+ }
+ self.resize_observer = None
+ self.detach_model() |> ignore
+ self.hide_placeholder()
+ self.placeholder = None
+ self.did_dispose.fire(())
+ self.did_change_model.dispose()
+ self.did_change_model_content.dispose()
+ self.did_scroll.dispose()
+ self.did_change_content_size.dispose()
+ self.did_dispose.dispose()
+ self.did_detach_for_diagnostics.dispose()
+ if self.owns_services {
+ self.services.dispose()
+ }
+}
diff --git a/editor/internal/viewer/markdown_viewer/markdown_viewer_definition.mbt b/editor/internal/viewer/markdown_viewer/markdown_viewer_definition.mbt
new file mode 100644
index 000000000..c277552b4
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/markdown_viewer_definition.mbt
@@ -0,0 +1,800 @@
+///|
+priv struct MarkdownViewerWidgetDefinitionTarget {
+ anchor : @goto_symbol.SymbolNavigationAnchor
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionTarget::same(
+ self : MarkdownViewerWidgetDefinitionTarget,
+ other : MarkdownViewerWidgetDefinitionTarget,
+) -> Bool {
+ self.anchor.same_snapshot(other.anchor)
+}
+
+///|
+/// OpenSeek Markdown IDE bridge. DOM/projection work remains surface-owned;
+/// provider policy and Peek lifecycle are delegated to the shared navigation
+/// action and one attachment-local ReferencesController.
+priv struct MarkdownViewerWidgetDefinitionBridge {
+ viewer : MarkdownViewerWidget
+ view : @markdown_document.MarkdownDocumentView
+ model : @model.TextModel
+ attach_generation : Int
+ listeners : Array[@base_common.Disposable]
+ navigation_action : @goto_symbol.SymbolNavigationAction
+ definition_action : @goto_symbol.DefinitionAction
+ references_action : @goto_symbol.ReferencesAction
+ link_resolver : @goto_symbol.DefinitionLinkResolver
+ controller : @references_browser.ReferencesController?
+ mut context_menu : @contextmenu_browser.ContextMenuWidget?
+ mut link_elements : Array[@rdom.Element]
+ mut last_target : MarkdownViewerWidgetDefinitionTarget?
+ mut mouse_down_target : MarkdownViewerWidgetDefinitionTarget?
+ mut last_client_x : Double
+ mut last_client_y : Double
+ mut surface_generation : Int
+ mut active : Bool
+ mut disposed : Bool
+}
+
+///|
+async fn markdown_navigation_run_provider_tasks(
+ tasks : Array[async () -> Unit],
+) -> Unit {
+ for task in tasks {
+ task()
+ }
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::MarkdownViewerWidgetDefinitionBridge(
+ viewer : MarkdownViewerWidget,
+ view : @markdown_document.MarkdownDocumentView,
+ model : @model.TextModel,
+ attach_generation : Int,
+) -> MarkdownViewerWidgetDefinitionBridge {
+ let bridge_slot : Ref[MarkdownViewerWidgetDefinitionBridge?] = Ref(None)
+ let anchor_is_current = anchor => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.anchor_is_current(anchor)
+ None => false
+ }
+ }
+ let navigation_action = @goto_symbol.SymbolNavigationAction(
+ viewer.services.languages(),
+ {
+ prepare_navigation: () => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.prepare_navigation()
+ None => ()
+ }
+ },
+ is_anchor_current: anchor_is_current,
+ open_location: (_anchor, location, mode, _source) => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.open_location(location, mode)
+ None => false
+ }
+ },
+ open_in_peek: (anchor, mode, locations) => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.open_in_peek(anchor, mode, locations)
+ None => false
+ }
+ },
+ show_message: (_anchor, message) => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.show_message(message)
+ None => ()
+ }
+ },
+ },
+ markdown_navigation_run_provider_tasks,
+ )
+ let link_resolver = @goto_symbol.DefinitionLinkResolver(
+ viewer.services.languages(),
+ is_anchor_current=anchor_is_current,
+ markdown_navigation_run_provider_tasks,
+ )
+ let controller = match viewer.services.preview_factory() {
+ None => None
+ Some(preview_factory) =>
+ Some(
+ @references_browser.ReferencesController(
+ {
+ is_anchor_current: anchor_is_current,
+ mount: (root, anchor) => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.mount_peek(root, anchor)
+ None => None
+ }
+ },
+ reveal_anchor: anchor => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.reveal_anchor(anchor)
+ None => ()
+ }
+ },
+ restore_source_focus: () => viewer.focus(),
+ open_location: (location, mode, _source) => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.open_location(location, mode)
+ None => false
+ }
+ },
+ show_message: (_anchor, message) => {
+ match bridge_slot.val {
+ Some(bridge) => bridge.show_message(message)
+ None => ()
+ }
+ },
+ },
+ preview_factory,
+ resolver?=viewer.services.text_model_resolver(),
+ task => viewer.services.launch_async_task(task),
+ ),
+ )
+ }
+ let bridge : MarkdownViewerWidgetDefinitionBridge = {
+ viewer,
+ view,
+ model,
+ attach_generation,
+ listeners: [],
+ navigation_action,
+ definition_action: DefinitionAction(navigation_action),
+ references_action: ReferencesAction(navigation_action),
+ link_resolver,
+ controller,
+ context_menu: None,
+ link_elements: [],
+ last_target: None,
+ mouse_down_target: None,
+ last_client_x: 0.0,
+ last_client_y: 0.0,
+ surface_generation: attach_generation,
+ active: false,
+ disposed: false,
+ }
+ bridge_slot.val = Some(bridge)
+ bridge
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::is_current(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Bool {
+ self.active &&
+ !self.disposed &&
+ !self.model.is_disposed() &&
+ self.viewer.is_current_attachment(self.model, self.attach_generation) &&
+ self.view.source_model_version() == self.model.get_version_id()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::anchor_is_current(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ anchor : @goto_symbol.SymbolNavigationAnchor,
+) -> Bool {
+ self.is_current() &&
+ anchor.surface_generation() == self.surface_generation &&
+ anchor.model().same(self.model) &&
+ anchor.model_is_current()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::target_is_current(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ target : MarkdownViewerWidgetDefinitionTarget,
+) -> Bool {
+ self.anchor_is_current(target.anchor)
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::target_at(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ client_x : Double,
+ client_y : Double,
+) -> MarkdownViewerWidgetDefinitionTarget? {
+ guard self.is_current() &&
+ self.view.semantic_pointer_hit(client_x, client_y) is Some(hit) else {
+ return None
+ }
+ let position = self.model.get_position_at(hit.source_offset)
+ guard self.model.get_word_at_position(position) is Some(word) else {
+ return None
+ }
+ let range = @base_common.Range(
+ position.line_number,
+ word.start_column,
+ position.line_number,
+ word.end_column,
+ )
+ Some({
+ anchor: @goto_symbol.SymbolNavigationAnchor::from_model(
+ self.model,
+ position,
+ range,
+ self.surface_generation,
+ ),
+ })
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::listen(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ element : @rdom.Element,
+ event_name : String,
+ listener : @rdom.Listener,
+) -> Unit {
+ element.add_event_listener(event_name, listener)
+ self.listeners.push(
+ @base_common.Disposable::from(() => {
+ element.remove_event_listener(event_name, listener)
+ }),
+ )
+}
+
+///|
+fn markdown_viewer_definition_modifier_is_active(
+ ctrl : Bool,
+ meta : Bool,
+ alt : Bool,
+ shift : Bool,
+) -> Bool {
+ guard !alt && !shift else { return false }
+ if @base_common.is_macintosh {
+ meta && !ctrl
+ } else {
+ ctrl && !meta
+ }
+}
+
+///|
+fn markdown_viewer_definition_modifier_key(key : String) -> Bool {
+ if @base_common.is_macintosh {
+ key == "Meta"
+ } else {
+ key == "Control"
+ }
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::clear_link_elements(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ for element in self.link_elements {
+ @base_browser.remove_element(element)
+ }
+ self.link_elements.clear()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::cancel_link_preview(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ self.link_resolver.cancel()
+ self.clear_link_elements()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::reset_link(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ self.cancel_link_preview()
+ self.mouse_down_target = None
+ self.last_target = None
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::start_link_preview(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ target : MarkdownViewerWidgetDefinitionTarget,
+) -> Unit {
+ guard self.target_is_current(target) &&
+ self.viewer.services.languages().has_definition_provider(self.model) else {
+ self.cancel_link_preview()
+ return
+ }
+ if self.last_target is Some(last) &&
+ last.same(target) &&
+ !self.link_elements.is_empty() {
+ return
+ }
+ self.cancel_link_preview()
+ self.last_target = Some(target)
+ guard self.link_resolver.begin(target.anchor) is Some(generation) else {
+ return
+ }
+ self.viewer.services.launch_async_task(async fn() noraise {
+ let locations = self.link_resolver.resolve_reserved(
+ target.anchor,
+ generation,
+ )
+ guard !locations.is_empty() && self.target_is_current(target) else {
+ return
+ }
+ let source_range = self.model.snapshot.offset_range_of(
+ target.anchor.range(),
+ )
+ self.link_elements = self.view.append_projected_range_spans(
+ source_range, "moonbit-viewer-markdown-definition-link",
+ )
+ })
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::prepare_navigation(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ self.cancel_link_preview()
+ self.hide_context_menu()
+ self.show_message("")
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::run_definition(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ target : MarkdownViewerWidgetDefinitionTarget,
+ config : @goto_symbol.SymbolNavigationActionConfig,
+) -> Unit {
+ guard self.target_is_current(target) &&
+ self.viewer.services.languages().has_definition_provider(self.model) &&
+ self.definition_action.begin(target.anchor) is Some(generation) else {
+ return
+ }
+ self.viewer.services.launch_async_task(async fn() noraise {
+ self.definition_action.run_reserved(target.anchor, config, generation)
+ })
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::run_references(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ target : MarkdownViewerWidgetDefinitionTarget,
+ config : @goto_symbol.SymbolNavigationActionConfig,
+) -> Unit {
+ guard self.target_is_current(target) &&
+ self.viewer.services.languages().has_references_provider(self.model) &&
+ self.references_action.begin(target.anchor) is Some(generation) else {
+ return
+ }
+ self.viewer.services.launch_async_task(async fn() noraise {
+ self.references_action.run_reserved(target.anchor, config, generation)
+ })
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::run_command(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ command_id : String,
+) -> Unit {
+ guard self.last_target is Some(target) && self.target_is_current(target) else {
+ return
+ }
+ match @goto_symbol.SymbolNavigationCommand::from_id(command_id) {
+ Some(RevealDefinition) =>
+ self.run_definition(
+ target,
+ @goto_symbol.SymbolNavigationActionConfig::goto(),
+ )
+ Some(PeekDefinition) =>
+ self.run_definition(
+ target,
+ @goto_symbol.SymbolNavigationActionConfig::peek(),
+ )
+ Some(GoToReferences) =>
+ self.run_references(
+ target,
+ @goto_symbol.SymbolNavigationActionConfig::goto(),
+ )
+ Some(PeekReferences) =>
+ self.run_references(
+ target,
+ @goto_symbol.SymbolNavigationActionConfig::peek(),
+ )
+ _ => ()
+ }
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::context_menu_entries(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Array[@contextmenu_browser.ContextMenuEntry] {
+ @goto_symbol.symbol_navigation_context_commands(
+ self.viewer.services.languages(),
+ self.model,
+ self.controller is Some(_),
+ ).map(command => {
+ ContextMenuAction(
+ id=command.id(),
+ label=command.title(),
+ keybinding_label=command.keybinding_label(),
+ )
+ })
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::get_or_create_context_menu(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> @contextmenu_browser.ContextMenuWidget? {
+ if self.disposed {
+ return None
+ }
+ match self.context_menu {
+ Some(widget) => Some(widget)
+ None => {
+ let widget = @contextmenu_browser.ContextMenuWidget({
+ on_action: command_id => self.run_command(command_id),
+ on_hide: _cancelled => (),
+ })
+ self.context_menu = Some(widget)
+ Some(widget)
+ }
+ }
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::hide_context_menu(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ match self.context_menu {
+ Some(widget) => widget.hide(was_cancelled=true)
+ None => ()
+ }
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::show_context_menu(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ target : MarkdownViewerWidgetDefinitionTarget,
+ client_x : Double,
+ client_y : Double,
+ auto_select_first? : Bool = false,
+) -> Bool {
+ guard self.target_is_current(target) else { return false }
+ let entries = self.context_menu_entries()
+ guard !entries.is_empty() && self.get_or_create_context_menu() is Some(widget) else {
+ return false
+ }
+ self.last_target = Some(target)
+ self.last_client_x = client_x
+ self.last_client_y = client_y
+ self.viewer.focus()
+ widget.show(
+ entries,
+ client_x,
+ client_y,
+ self.viewer.options.theme,
+ theme_source=self.view.root(),
+ auto_select_first~,
+ )
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::show_keyboard_context_menu(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Bool {
+ guard self.last_target is Some(target) && self.target_is_current(target) else {
+ return false
+ }
+ let rect = self.view.root().get_bounding_client_rect()
+ let x = if self.last_client_x > 0.0 {
+ self.last_client_x
+ } else {
+ rect.get_left() + 24.0
+ }
+ let y = if self.last_client_y > 0.0 {
+ self.last_client_y
+ } else {
+ rect.get_top() + 24.0
+ }
+ self.show_context_menu(target, x, y, auto_select_first=true)
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::bind_semantic_dom(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ guard self.is_current() else { return }
+ self.view.listen_semantic_dom("mousemove", event => {
+ guard self.is_current() && event.to_mouse_event() is Some(mouse) else {
+ self.cancel_link_preview()
+ return
+ }
+ self.last_client_x = mouse.get_client_x().to_double()
+ self.last_client_y = mouse.get_client_y().to_double()
+ let target = self.target_at(self.last_client_x, self.last_client_y)
+ self.last_target = target
+ if mouse.get_buttons() == 0 &&
+ markdown_viewer_definition_modifier_is_active(
+ mouse.get_ctrl_key(),
+ mouse.get_meta_key(),
+ mouse.get_alt_key(),
+ mouse.get_shift_key(),
+ ) {
+ match target {
+ Some(target) => self.start_link_preview(target)
+ None => self.cancel_link_preview()
+ }
+ } else {
+ self.cancel_link_preview()
+ }
+ })
+ self.view.listen_semantic_dom("contextmenu", event => {
+ guard self.is_current() &&
+ event.to_mouse_event() is Some(mouse) &&
+ self.target_at(
+ mouse.get_client_x().to_double(),
+ mouse.get_client_y().to_double(),
+ )
+ is Some(target) else {
+ return
+ }
+ if self.show_context_menu(
+ target,
+ mouse.get_client_x().to_double(),
+ mouse.get_client_y().to_double(),
+ ) {
+ event.prevent_default()
+ event.stop_propagation()
+ }
+ })
+ self.view.listen_semantic_dom("mousedown", event => {
+ guard self.is_current() && event.to_mouse_event() is Some(mouse) else {
+ return
+ }
+ let standard = @base_browser.StandardMouseEvent(mouse)
+ guard standard.left_button && standard.detail <= 1 else { return }
+ self.viewer.focus()
+ self.mouse_down_target = self.target_at(
+ mouse.get_client_x().to_double(),
+ mouse.get_client_y().to_double(),
+ )
+ self.last_target = self.mouse_down_target
+ })
+ self.view.listen_semantic_dom("mouseup", event => {
+ guard self.is_current() && event.to_mouse_event() is Some(mouse) else {
+ return
+ }
+ let standard = @base_browser.StandardMouseEvent(mouse)
+ let target = self.target_at(
+ mouse.get_client_x().to_double(),
+ mouse.get_client_y().to_double(),
+ )
+ let down = self.mouse_down_target
+ self.mouse_down_target = None
+ guard standard.left_button &&
+ standard.detail <= 1 &&
+ markdown_viewer_definition_modifier_is_active(
+ standard.ctrl_key,
+ standard.meta_key,
+ standard.alt_key,
+ standard.shift_key,
+ ) &&
+ down is Some(down) &&
+ target is Some(target) &&
+ down.same(target) &&
+ self.target_is_current(target) else {
+ return
+ }
+ self.last_target = Some(target)
+ self.run_definition(
+ target,
+ @goto_symbol.SymbolNavigationActionConfig::link(),
+ )
+ event.prevent_default()
+ event.stop_propagation()
+ })
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::activate(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ guard !self.disposed && !self.active else { return }
+ self.active = true
+ self.listen(self.view.root(), "keydown", event => {
+ guard self.is_current() &&
+ @base_browser.event_target_is(event, self.view.root()) &&
+ event.to_keyboard_event() is Some(keyboard) else {
+ return
+ }
+ if markdown_viewer_definition_modifier_key(keyboard.key()) {
+ match self.last_target {
+ Some(target) if markdown_viewer_definition_modifier_is_active(
+ keyboard.ctrl_key(),
+ keyboard.meta_key(),
+ keyboard.alt_key(),
+ keyboard.shift_key(),
+ ) => self.start_link_preview(target)
+ _ => self.cancel_link_preview()
+ }
+ return
+ }
+ let handled = if keyboard.key() == "F12" {
+ if keyboard.alt_key() && !keyboard.shift_key() {
+ self.run_command("editor.action.peekDefinition")
+ true
+ } else if keyboard.shift_key() && !keyboard.alt_key() {
+ self.run_command("editor.action.goToReferences")
+ true
+ } else if !keyboard.alt_key() && !keyboard.shift_key() {
+ self.run_command("editor.action.revealDefinition")
+ true
+ } else {
+ false
+ }
+ } else if keyboard.key() == "ContextMenu" ||
+ (keyboard.key() == "F10" && keyboard.shift_key()) {
+ self.show_keyboard_context_menu()
+ } else {
+ false
+ }
+ if handled {
+ event.prevent_default()
+ event.stop_propagation()
+ }
+ })
+ self.listen(self.view.root(), "keyup", event => {
+ guard event.to_keyboard_event() is Some(keyboard) else { return }
+ if markdown_viewer_definition_modifier_key(keyboard.key()) {
+ self.cancel_link_preview()
+ }
+ })
+ self.listen(self.view.root(), "mouseleave", _ => self.cancel_link_preview())
+ self.listen(self.view.root(), "blur", _ => self.cancel_link_preview())
+ self.listen(self.view.viewport(), "scroll", _ => {
+ self.cancel_link_preview()
+ self.hide_context_menu()
+ })
+ self.bind_semantic_dom()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::show_message(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ message : String,
+) -> Unit {
+ if message.is_empty() {
+ self.view.root().remove_attribute("data-navigation-message")
+ } else {
+ self.view.root().set_attribute("data-navigation-message", message)
+ }
+}
+
+///|
+async fn MarkdownViewerWidgetDefinitionBridge::open_location(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ location : @language.Location,
+ mode : @navigation_api.OpenLocationMode,
+) -> Bool noraise {
+ guard self.is_current() else { return false }
+ if mode == Current && self.model.uri.to_string() == location.uri.to_string() {
+ let range = self.model.validate_range(location.range)
+ self.view.reveal_source_offset(
+ self.model.get_offset_at(range.get_start_position()),
+ "center",
+ )
+ self.viewer.focus()
+ return true
+ }
+ match self.viewer.services.location_opener() {
+ Some(opener) =>
+ opener.open({ source_viewer_id: self.viewer.get_id(), location, mode })
+ None => false
+ }
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::open_in_peek(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ anchor : @goto_symbol.SymbolNavigationAnchor,
+ mode : @references.ReferencesPeekMode,
+ locations : Array[@language.Location],
+) -> Bool {
+ guard self.anchor_is_current(anchor) && self.controller is Some(controller) else {
+ return false
+ }
+ controller.toggle_locations(anchor, mode, locations)
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::mount_peek(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ root : @rdom.Element,
+ anchor : @goto_symbol.SymbolNavigationAnchor,
+) -> @references_browser.ReferencesPeekMount? {
+ guard self.anchor_is_current(anchor) else { return None }
+ root.set_attribute(
+ "class", "moonbit-viewer-references-peek moonbit-viewer-references-peek-overlay",
+ )
+ self.view.overlay_mount().append_child(root.as_node())
+ let disposed = Ref(false)
+ Some(
+ ReferencesPeekMount(
+ is_current=() => {
+ !disposed.val &&
+ self.anchor_is_current(anchor) &&
+ @base_browser.element_contains(self.view.overlay_mount(), root)
+ },
+ dispose=() => {
+ if !disposed.val {
+ disposed.val = true
+ @base_browser.remove_element(root)
+ }
+ },
+ ),
+ )
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::reveal_anchor(
+ self : MarkdownViewerWidgetDefinitionBridge,
+ anchor : @goto_symbol.SymbolNavigationAnchor,
+) -> Unit {
+ guard self.anchor_is_current(anchor) else { return }
+ self.view.reveal_source_offset(
+ self.model.get_offset_at(anchor.position()),
+ "center",
+ )
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::before_projection_replaced(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ guard !self.disposed else { return }
+ self.surface_generation += 1
+ self.navigation_action.cancel()
+ self.link_resolver.cancel()
+ match self.controller {
+ Some(controller) => controller.close(restore_focus=false)
+ None => ()
+ }
+ self.reset_link()
+ self.hide_context_menu()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::after_projection_replaced(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ guard !self.disposed else { return }
+ self.bind_semantic_dom()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::layout_changed(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ guard !self.disposed else { return }
+ self.cancel_link_preview()
+ self.hide_context_menu()
+}
+
+///|
+fn MarkdownViewerWidgetDefinitionBridge::dispose(
+ self : MarkdownViewerWidgetDefinitionBridge,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.active = false
+ self.navigation_action.dispose()
+ self.link_resolver.dispose()
+ match self.controller {
+ Some(controller) => controller.dispose()
+ None => ()
+ }
+ self.reset_link()
+ match self.context_menu {
+ Some(widget) => widget.dispose()
+ None => ()
+ }
+ self.context_menu = None
+ while self.listeners.length() > 0 {
+ self.listeners.remove(self.listeners.length() - 1).dispose()
+ }
+}
diff --git a/editor/internal/viewer/markdown_viewer/markdown_viewer_folding.mbt b/editor/internal/viewer/markdown_viewer/markdown_viewer_folding.mbt
new file mode 100644
index 000000000..edadebbf3
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/markdown_viewer_folding.mbt
@@ -0,0 +1,242 @@
+///|
+priv struct MarkdownViewerWidgetFoldRecord {
+ mut collapsed : Bool
+ fingerprint : String
+}
+
+///|
+/// Attachment-scoped section state. Records are never carried across a model
+/// swap; source replacement retains a reader decision only when both the
+/// section key and exact body fingerprint remain unchanged.
+priv struct MarkdownViewerWidgetFoldState {
+ mut source : String
+ mut section_keys : Array[String]
+ records : Map[String, MarkdownViewerWidgetFoldRecord]
+}
+
+///|
+fn MarkdownViewerWidgetFoldState::MarkdownViewerWidgetFoldState() -> MarkdownViewerWidgetFoldState {
+ { source: "", section_keys: [], records: Map([]) }
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::initialize_folding(
+ self : MarkdownViewerWidgetAttachment,
+) -> Unit {
+ guard self.view is Some(view) else { return }
+ let source = self.model.model.get_value()
+ let projection = view.projection()
+ let sections = @markdown_core.markdown_sections(projection)
+ let keys = @markdown_core.markdown_section_keys(sections, projection, source)
+ let defaults = @markdown_core.markdown_default_collapsed_sections(
+ projection, source,
+ )
+ self.fold.source = source
+ self.fold.section_keys = keys
+ self.fold.records.clear()
+ for section_index, section in sections {
+ guard section_index < keys.length() else { continue }
+ self.fold.records[keys[section_index]] = {
+ collapsed: defaults.contains(section_index),
+ fingerprint: markdown_viewer_fold_fingerprint(section, source),
+ }
+ }
+ view.set_section_fold_toggle_handler(section_index => {
+ self.toggle_section(section_index) |> ignore
+ })
+ self.apply_folding()
+ self.apply_toc()
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::reconcile_folding(
+ self : MarkdownViewerWidgetAttachment,
+ source : String,
+) -> Unit {
+ guard self.view is Some(view) else { return }
+ let projection = view.projection()
+ let sections = @markdown_core.markdown_sections(projection)
+ let keys = @markdown_core.markdown_section_keys(sections, projection, source)
+ let retained : Map[String, MarkdownViewerWidgetFoldRecord] = Map([])
+ for section_index, section in sections {
+ guard section_index < keys.length() else { continue }
+ let key = keys[section_index]
+ let fingerprint = markdown_viewer_fold_fingerprint(section, source)
+ retained[key] = match self.fold.records.get(key) {
+ Some(record) if record.fingerprint == fingerprint => record
+ _ => { collapsed: false, fingerprint }
+ }
+ }
+ self.fold.source = source
+ self.fold.section_keys = keys
+ self.fold.records.clear()
+ for key, record in retained {
+ self.fold.records[key] = record
+ }
+ self.apply_folding()
+ self.apply_toc()
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::toggle_section(
+ self : MarkdownViewerWidgetAttachment,
+ section_index : Int,
+) -> Bool {
+ guard section_index >= 0 &&
+ section_index < self.fold.section_keys.length() &&
+ self.fold.records.get(self.fold.section_keys[section_index]) is Some(record) else {
+ return false
+ }
+ record.collapsed = !record.collapsed
+ self.apply_folding()
+ self.layout_changed()
+ true
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::apply_folding(
+ self : MarkdownViewerWidgetAttachment,
+) -> Unit {
+ guard self.view is Some(view) else { return }
+ let projection = view.projection()
+ let sections = @markdown_core.markdown_sections(projection)
+ let hidden : Set[Int] = Set([])
+ let controls : Array[@markdown_document.MarkdownSectionFoldControl] = []
+ for section_index, section in sections {
+ guard section.is_foldable(projection) &&
+ section_index < self.fold.section_keys.length() else {
+ continue
+ }
+ let collapsed = match
+ self.fold.records.get(self.fold.section_keys[section_index]) {
+ Some(record) => record.collapsed
+ None => false
+ }
+ match
+ projection.block_anchors[section.heading_anchor_index].rendered_element_index {
+ Some(heading_element_index) =>
+ controls.push({ heading_element_index, section_index, collapsed })
+ None => continue
+ }
+ if collapsed {
+ for element_index in section.body_rendered_element_indexes(projection) {
+ hidden.add(element_index)
+ }
+ }
+ }
+ let hidden_sorted = hidden.to_array()
+ hidden_sorted.sort()
+ view.set_hidden_root_elements(hidden_sorted)
+ view.install_section_fold_controls(controls)
+}
+
+///|
+fn markdown_viewer_fold_fingerprint(
+ section : @markdown_core.MarkdownSection,
+ source : String,
+) -> String {
+ match section.body_source_range {
+ Some(range) => {
+ let start = range.start.max(0).min(source.length())
+ let end = range.end_exclusive.max(start).min(source.length())
+ source[start:end].to_owned()
+ }
+ None => ""
+ }
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::apply_toc(
+ self : MarkdownViewerWidgetAttachment,
+) -> Unit {
+ guard self.view is Some(view) else { return }
+ let entries = @markdown_core.markdown_table_of_contents(view.projection()).map(entry => {
+ let bar_entry : @markdown_document.MarkdownTocBarEntry = {
+ depth: entry.depth,
+ text: entry.text,
+ source_offset: entry.source_offset,
+ }
+ bar_entry
+ },
+ )
+ view.set_toc_entries(entries, offset => self.navigate_section(offset))
+}
+
+///|
+fn MarkdownViewerWidgetAttachment::navigate_section(
+ self : MarkdownViewerWidgetAttachment,
+ source_offset : Int,
+) -> Unit {
+ guard self.view is Some(view) else { return }
+ let projection = view.projection()
+ let sections = @markdown_core.markdown_sections(projection)
+ let mut changed = false
+ for section_index, section in sections {
+ guard section.source_range.contains(source_offset) &&
+ section_index < self.fold.section_keys.length() else {
+ continue
+ }
+ match self.fold.records.get(self.fold.section_keys[section_index]) {
+ Some(record) if record.collapsed => {
+ record.collapsed = false
+ changed = true
+ }
+ _ => ()
+ }
+ }
+ if changed {
+ self.apply_folding()
+ }
+ self.layout_changed()
+}
+
+///|
+/// Collapses or expands the innermost foldable section containing the source
+/// offset. Offsets are 0-based UTF-16 positions in the original TextModel.
+pub fn MarkdownViewerWidget::toggle_section_at(
+ self : MarkdownViewerWidget,
+ source_offset : Int,
+) -> Bool {
+ guard self.current is Some(current) && current.view is Some(view) else {
+ return false
+ }
+ let projection = view.projection()
+ let sections = @markdown_core.markdown_sections(projection)
+ guard @markdown_core.markdown_section_at_source_offset(
+ sections, source_offset,
+ )
+ is Some(section) &&
+ section.is_foldable(projection) else {
+ return false
+ }
+ for section_index, candidate in sections {
+ if candidate.heading_anchor_index == section.heading_anchor_index {
+ return current.toggle_section(section_index)
+ }
+ }
+ false
+}
+
+///|
+/// Returns collapsed heading offsets in document order.
+pub fn MarkdownViewerWidget::collapsed_sections(
+ self : MarkdownViewerWidget,
+) -> Array[Int] {
+ let collapsed : Array[Int] = []
+ guard self.current is Some(current) && current.view is Some(view) else {
+ return collapsed
+ }
+ let projection = view.projection()
+ let sections = @markdown_core.markdown_sections(projection)
+ for section_index, section in sections {
+ guard section_index < current.fold.section_keys.length() else { continue }
+ match current.fold.records.get(current.fold.section_keys[section_index]) {
+ Some(record) if record.collapsed =>
+ collapsed.push(
+ projection.block_anchors[section.heading_anchor_index].source_range.start,
+ )
+ _ => ()
+ }
+ }
+ collapsed
+}
diff --git a/editor/internal/viewer/markdown_viewer/markdown_viewer_test.mbt b/editor/internal/viewer/markdown_viewer/markdown_viewer_test.mbt
new file mode 100644
index 000000000..8ac66d44c
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/markdown_viewer_test.mbt
@@ -0,0 +1,48 @@
+///|
+fn public_markdown_viewer_test_model() -> @model.TextModel {
+ TextModel(
+ try! @base_common.Uri::from(
+ scheme="inmemory",
+ authority="markdown-viewer-public-test",
+ path="/source.mbt",
+ ),
+ "source.mbt",
+ "moonbit",
+ 1,
+ "public-test-revision",
+ "```mbt check\nlet answer = 42\n```",
+ )
+}
+
+///|
+test "public MarkdownViewerWidget model makes resource semantics explicit" {
+ let model = public_markdown_viewer_test_model()
+ defer model.dispose()
+ let ordinary = @markdown_viewer.MarkdownViewerWidgetModel(
+ model,
+ OrdinaryMarkdown,
+ )
+ let moonbit = @markdown_viewer.MarkdownViewerWidgetModel(
+ model,
+ MoonBitMarkdown,
+ )
+ assert_true(ordinary.model.same(model))
+ assert_true(ordinary.resource_kind == OrdinaryMarkdown)
+ assert_true(moonbit.model.same(model))
+ assert_true(moonbit.resource_kind == MoonBitMarkdown)
+}
+
+///|
+test "public MarkdownViewerWidget options contain no code-editor policy" {
+ let defaults = @markdown_viewer.MarkdownViewerWidgetOptions::default()
+ assert_eq(defaults.theme(), "dark")
+ assert_eq(defaults.placeholder(), "")
+ assert_true(defaults.show_unused())
+ let changed = defaults
+ .with_theme("light")
+ .with_placeholder("Choose a Markdown document")
+ .with_show_unused(false)
+ assert_eq(changed.theme(), "light")
+ assert_eq(changed.placeholder(), "Choose a Markdown document")
+ assert_false(changed.show_unused())
+}
diff --git a/editor/internal/viewer/markdown_viewer/markdown_viewer_wbtest.mbt b/editor/internal/viewer/markdown_viewer/markdown_viewer_wbtest.mbt
new file mode 100644
index 000000000..71d71658b
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/markdown_viewer_wbtest.mbt
@@ -0,0 +1,130 @@
+///|
+fn markdown_viewer_lifecycle_test_model(
+ path : String,
+ source : String,
+) -> @model.TextModel {
+ TextModel(
+ try! @base_common.Uri::from(
+ scheme="inmemory",
+ authority="markdown-viewer-lifecycle-test",
+ path~,
+ ),
+ path,
+ "moonbit",
+ 1,
+ "markdown-viewer-lifecycle-revision",
+ source,
+ )
+}
+
+///|
+test "MarkdownViewerWidget detaches the returned old model before set_model returns" {
+ let first = markdown_viewer_lifecycle_test_model(
+ "/first.mbt", "# First\n\n```mbt check\nlet first = 1\n```",
+ )
+ let second = markdown_viewer_lifecycle_test_model(
+ "/second.mbt", "# Second\n\n```mbt check\nlet second = 2\n```",
+ )
+ let viewer = MarkdownViewerWidget::MarkdownViewerWidget()
+ defer {
+ viewer.dispose()
+ first.dispose()
+ second.dispose()
+ }
+ let detached_after_cleanup = Ref(false)
+ let detach_count = Ref(0)
+ let detach_listener = viewer.on_did_detach_for_diagnostics(() => {
+ detach_count.val += 1
+ detached_after_cleanup.val = viewer.current is None
+ })
+ defer detach_listener.dispose()
+ let content_deliveries = Ref(0)
+ let content_listener = viewer.on_did_change_model_content(_ => {
+ content_deliveries.val += 1
+ })
+ defer content_listener.dispose()
+ assert_true(
+ viewer.set_model(Some(MarkdownViewerWidgetModel(first, MoonBitMarkdown)))
+ is None,
+ )
+ let returned = viewer.set_model(
+ Some(MarkdownViewerWidgetModel(second, OrdinaryMarkdown)),
+ )
+ guard returned is Some(returned) else { fail("expected detached old model") }
+ assert_true(returned.model.same(first))
+ assert_true(returned.resource_kind == MoonBitMarkdown)
+ assert_eq(detach_count.val, 1)
+ assert_true(detached_after_cleanup.val)
+ guard viewer.current is Some(current) else { fail("expected replacement") }
+ assert_true(current.model.model.same(second))
+ assert_true(current.model.resource_kind == OrdinaryMarkdown)
+ first.set_value("detached change")
+ assert_eq(content_deliveries.val, 0)
+ second.set_value("# replacement changed")
+ assert_eq(content_deliveries.val, 1)
+}
+
+///|
+test "explicit resource kind renders a code model as Markdown without classification" {
+ let model = markdown_viewer_lifecycle_test_model(
+ "/plain-code.mbt", "```mbt check\n///|\nfn rendered_as_markdown() -> Int { 1 }\n```",
+ )
+ let viewer = MarkdownViewerWidget::MarkdownViewerWidget()
+ defer {
+ viewer.dispose()
+ model.dispose()
+ }
+ viewer.set_model(Some(MarkdownViewerWidgetModel(model, MoonBitMarkdown)))
+ |> ignore
+ guard viewer.current is Some(current) else { fail("expected attachment") }
+ assert_true(current.model.model.same(model))
+ assert_true(current.model.resource_kind == MoonBitMarkdown)
+ assert_true(current.model.resource_kind.to_internal() == MoonBitMarkdown)
+ // A headless package test has no DOM, but it follows the same attachment
+ // constructor and explicit kind path as the mounted MarkdownDocumentView.
+ assert_true(current.view is None)
+}
+
+///|
+test "changing only resource kind is a real detach and reattach" {
+ let model = markdown_viewer_lifecycle_test_model(
+ "/resource-kind.mbt", "```mbt check\nlet value = 1\n```",
+ )
+ let viewer = MarkdownViewerWidget::MarkdownViewerWidget()
+ defer {
+ viewer.dispose()
+ model.dispose()
+ }
+ viewer.set_model(Some(MarkdownViewerWidgetModel(model, OrdinaryMarkdown)))
+ |> ignore
+ let detach_count = Ref(0)
+ let listener = viewer.on_did_detach_for_diagnostics(() => {
+ detach_count.val += 1
+ })
+ defer listener.dispose()
+ let old = viewer.set_model(
+ Some(MarkdownViewerWidgetModel(model, MoonBitMarkdown)),
+ )
+ guard old is Some(old) else { fail("expected old explicit model") }
+ assert_true(old.model.same(model))
+ assert_true(old.resource_kind == OrdinaryMarkdown)
+ assert_eq(detach_count.val, 1)
+ guard viewer.get_model() is Some(current) else {
+ fail("expected current model")
+ }
+ assert_true(current.model.same(model))
+ assert_true(current.resource_kind == MoonBitMarkdown)
+}
+
+///|
+test "disposing an installed source model clears MarkdownViewerWidget synchronously" {
+ let model = markdown_viewer_lifecycle_test_model(
+ "/disposed.mbt", "# Dispose me",
+ )
+ let viewer = MarkdownViewerWidget::MarkdownViewerWidget()
+ defer viewer.dispose()
+ viewer.set_model(Some(MarkdownViewerWidgetModel(model, OrdinaryMarkdown)))
+ |> ignore
+ model.dispose()
+ assert_true(viewer.get_model() is None)
+}
diff --git a/editor/internal/viewer/markdown_viewer/moon.pkg b/editor/internal/viewer/markdown_viewer/moon.pkg
new file mode 100644
index 000000000..4e496a62d
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/moon.pkg
@@ -0,0 +1,23 @@
+import {
+ "moonbitlang/editor/base/browser" @base_browser,
+ "moonbitlang/editor/base/common" @base_common,
+ "moonbitlang/editor/language",
+ "moonbitlang/editor/platform/log",
+ "moonbitlang/editor/internal/viewer/browser/markdown_document",
+ "moonbitlang/editor/internal/viewer/contrib/contextmenu/browser" @contextmenu_browser,
+ "moonbitlang/editor/internal/viewer/contrib/goto_symbol",
+ "moonbitlang/editor/internal/viewer/contrib/hover",
+ "moonbitlang/editor/internal/viewer/contrib/hover/browser" @hover_browser,
+ "moonbitlang/editor/internal/viewer/contrib/references",
+ "moonbitlang/editor/internal/viewer/contrib/references/browser" @references_browser,
+ "moonbitlang/editor/internal/viewer/markdown" @markdown_core,
+ "moonbitlang/editor/viewer/common/editor_api",
+ "moonbitlang/editor/viewer/common/languages",
+ "moonbitlang/editor/viewer/common/markers",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
+ "moonbit-community/rabbita/dom" @rdom,
+ "moonbit-community/rabbita/js",
+}
+
+supported_targets = "js"
diff --git a/editor/internal/viewer/markdown_viewer/pkg.generated.mbti b/editor/internal/viewer/markdown_viewer/pkg.generated.mbti
new file mode 100644
index 000000000..03ae70729
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/pkg.generated.mbti
@@ -0,0 +1,84 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/editor/internal/viewer/markdown_viewer"
+
+import {
+ "moonbit-community/rabbita/dom",
+ "moonbitlang/core/debug",
+ "moonbitlang/editor/base/common",
+ "moonbitlang/editor/internal/viewer/contrib/references/browser",
+ "moonbitlang/editor/platform/log",
+ "moonbitlang/editor/viewer/common/editor_api",
+ "moonbitlang/editor/viewer/common/languages",
+ "moonbitlang/editor/viewer/common/markers",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
+}
+
+// Values
+
+// Errors
+
+// Types and methods
+pub(all) enum MarkdownResourceKind {
+ OrdinaryMarkdown
+ MoonBitMarkdown
+} derive(Eq, @debug.Debug)
+
+type MarkdownViewerServices
+pub fn MarkdownViewerServices::MarkdownViewerServices(languages? : @languages.LanguageHandle, markers? : @markers.MarkerDecorationsSource, location_opener? : @navigation_api.LocationOpenerHandle, text_model_resolver? : @navigation_api.TextModelResolverHandle, preview_factory? : @browser.ReferencesPeekPreviewFactory, launch_async_task? : (async () -> Unit noraise) -> Unit, log_service? : @log.LogHandle) -> Self
+pub fn MarkdownViewerServices::dispose(Self) -> Unit
+pub fn MarkdownViewerServices::languages(Self) -> @languages.LanguageHandle
+pub fn MarkdownViewerServices::launch_async_task(Self, async () -> Unit noraise) -> Unit
+pub fn MarkdownViewerServices::location_opener(Self) -> @navigation_api.LocationOpenerHandle?
+pub fn MarkdownViewerServices::log_service(Self) -> @log.LogHandle
+pub fn MarkdownViewerServices::marker_decorations(Self) -> @markers.MarkerDecorationsHandle
+pub fn MarkdownViewerServices::preview_factory(Self) -> @browser.ReferencesPeekPreviewFactory?
+pub fn MarkdownViewerServices::text_model_resolver(Self) -> @navigation_api.TextModelResolverHandle?
+
+type MarkdownViewerWidget
+pub fn MarkdownViewerWidget::collapsed_sections(Self) -> Array[Int]
+pub fn MarkdownViewerWidget::create(@dom.Element, services? : MarkdownViewerServices, options? : MarkdownViewerWidgetOptions) -> Self
+pub fn MarkdownViewerWidget::dispose(Self) -> Unit
+pub fn MarkdownViewerWidget::focus(Self) -> Unit
+pub fn MarkdownViewerWidget::get_container_dom_node(Self) -> @dom.Element
+pub fn MarkdownViewerWidget::get_content_height(Self) -> Double
+pub fn MarkdownViewerWidget::get_content_width(Self) -> Double
+pub fn MarkdownViewerWidget::get_dom_node(Self) -> @dom.Element?
+pub fn MarkdownViewerWidget::get_id(Self) -> String
+pub fn MarkdownViewerWidget::get_model(Self) -> MarkdownViewerWidgetModel?
+pub fn MarkdownViewerWidget::get_options(Self) -> MarkdownViewerWidgetOptions
+pub fn MarkdownViewerWidget::has_text_focus(Self) -> Bool
+pub fn MarkdownViewerWidget::layout(Self) -> Unit
+pub fn MarkdownViewerWidget::on_did_change_content_size(Self, () -> Unit) -> @common.Disposable
+pub fn MarkdownViewerWidget::on_did_change_model(Self, (@editor_api.ModelChangedEvent) -> Unit) -> @common.Disposable
+pub fn MarkdownViewerWidget::on_did_change_model_content(Self, (@model.ModelContentChangedEvent) -> Unit) -> @common.Disposable
+pub fn MarkdownViewerWidget::on_did_dispose(Self, () -> Unit) -> @common.Disposable
+pub fn MarkdownViewerWidget::on_did_scroll(Self, () -> Unit) -> @common.Disposable
+pub fn MarkdownViewerWidget::restore_view_state(Self, MarkdownViewerWidgetViewState?) -> Unit
+pub fn MarkdownViewerWidget::reveal_source_offset(Self, Int) -> Unit
+pub fn MarkdownViewerWidget::save_view_state(Self) -> MarkdownViewerWidgetViewState?
+pub fn MarkdownViewerWidget::set_model(Self, MarkdownViewerWidgetModel?) -> MarkdownViewerWidgetModel?
+pub fn MarkdownViewerWidget::toggle_section_at(Self, Int) -> Bool
+pub fn MarkdownViewerWidget::update_options(Self, MarkdownViewerWidgetOptions) -> Unit
+
+pub(all) struct MarkdownViewerWidgetModel {
+ model : @model.TextModel
+ resource_kind : MarkdownResourceKind
+} derive(@debug.Debug)
+pub fn MarkdownViewerWidgetModel::MarkdownViewerWidgetModel(@model.TextModel, MarkdownResourceKind) -> Self
+
+type MarkdownViewerWidgetOptions derive(Eq, @debug.Debug)
+pub fn MarkdownViewerWidgetOptions::MarkdownViewerWidgetOptions(theme? : String, placeholder? : String, show_unused? : Bool) -> Self
+pub fn MarkdownViewerWidgetOptions::default() -> Self
+pub fn MarkdownViewerWidgetOptions::placeholder(Self) -> String
+pub fn MarkdownViewerWidgetOptions::show_unused(Self) -> Bool
+pub fn MarkdownViewerWidgetOptions::theme(Self) -> String
+pub fn MarkdownViewerWidgetOptions::with_placeholder(Self, String) -> Self
+pub fn MarkdownViewerWidgetOptions::with_show_unused(Self, Bool) -> Self
+pub fn MarkdownViewerWidgetOptions::with_theme(Self, String) -> Self
+
+type MarkdownViewerWidgetViewState derive(Eq, @debug.Debug)
+
+// Type aliases
+
+// Traits
diff --git a/editor/internal/viewer/markdown_viewer/services.mbt b/editor/internal/viewer/markdown_viewer/services.mbt
new file mode 100644
index 000000000..40b40377c
--- /dev/null
+++ b/editor/internal/viewer/markdown_viewer/services.mbt
@@ -0,0 +1,132 @@
+///|
+/// The service capabilities used by the standalone Markdown surface. This
+/// package owns no CodeEditorWidget and does not import its service façade.
+struct MarkdownViewerServices {
+ languages : @languages.LanguageHandle
+ marker_decorations : @markers.MarkerDecorationsHandle
+ location_opener : @navigation_api.LocationOpenerHandle?
+ text_model_resolver : @navigation_api.TextModelResolverHandle?
+ preview_factory : @references_browser.ReferencesPeekPreviewFactory?
+ launch_async_task : (async () -> Unit noraise) -> Unit
+ log_service : @log.LogHandle
+ owned_marker_decorations : @markers.MarkerDecorationsService?
+ owned_markers : @markers.MarkerService?
+ mut disposed : Bool
+}
+
+///|
+/// Builds a Markdown-only service bundle. Supplied handles remain caller-owned;
+/// omitted marker services are created and released by this bundle.
+pub fn MarkdownViewerServices::MarkdownViewerServices(
+ languages? : @languages.LanguageHandle,
+ markers? : @markers.MarkerDecorationsSource,
+ location_opener? : @navigation_api.LocationOpenerHandle,
+ text_model_resolver? : @navigation_api.TextModelResolverHandle,
+ preview_factory? : @references_browser.ReferencesPeekPreviewFactory,
+ launch_async_task? : (async () -> Unit noraise) -> Unit = task => {
+ @js.async_run(task)
+ },
+ log_service? : @log.LogHandle,
+) -> MarkdownViewerServices {
+ let log_service = match log_service {
+ Some(handle) => handle
+ None => @log.LogService().log_handle()
+ }
+ let languages = match languages {
+ Some(handle) => handle
+ None => @languages.default_languages().language_handle(log_service)
+ }
+ let (marker_decorations, owned_marker_decorations, owned_markers) = match
+ markers {
+ None => {
+ let store = @markers.MarkerService(schedule=@base_browser.queue_microtask)
+ let decorations = @markers.MarkerDecorationsService(
+ store.marker_service_handle(),
+ )
+ (decorations.marker_decorations_handle(), Some(decorations), Some(store))
+ }
+ Some(MarkerStore(store)) => {
+ let decorations = @markers.MarkerDecorationsService(store)
+ (decorations.marker_decorations_handle(), Some(decorations), None)
+ }
+ Some(Decorations(decorations)) => (decorations, None, None)
+ }
+ {
+ languages,
+ marker_decorations,
+ location_opener,
+ text_model_resolver,
+ preview_factory,
+ launch_async_task,
+ log_service,
+ owned_marker_decorations,
+ owned_markers,
+ disposed: false,
+ }
+}
+
+///|
+pub fn MarkdownViewerServices::languages(
+ self : MarkdownViewerServices,
+) -> @languages.LanguageHandle {
+ self.languages
+}
+
+///|
+pub fn MarkdownViewerServices::marker_decorations(
+ self : MarkdownViewerServices,
+) -> @markers.MarkerDecorationsHandle {
+ self.marker_decorations
+}
+
+///|
+pub fn MarkdownViewerServices::location_opener(
+ self : MarkdownViewerServices,
+) -> @navigation_api.LocationOpenerHandle? {
+ self.location_opener
+}
+
+///|
+pub fn MarkdownViewerServices::text_model_resolver(
+ self : MarkdownViewerServices,
+) -> @navigation_api.TextModelResolverHandle? {
+ self.text_model_resolver
+}
+
+///|
+pub fn MarkdownViewerServices::preview_factory(
+ self : MarkdownViewerServices,
+) -> @references_browser.ReferencesPeekPreviewFactory? {
+ self.preview_factory
+}
+
+///|
+pub fn MarkdownViewerServices::launch_async_task(
+ self : MarkdownViewerServices,
+ task : async () -> Unit noraise,
+) -> Unit {
+ (self.launch_async_task)(task)
+}
+
+///|
+pub fn MarkdownViewerServices::log_service(
+ self : MarkdownViewerServices,
+) -> @log.LogHandle {
+ self.log_service
+}
+
+///|
+pub fn MarkdownViewerServices::dispose(self : MarkdownViewerServices) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ match self.owned_marker_decorations {
+ Some(service) => service.dispose()
+ None => ()
+ }
+ match self.owned_markers {
+ Some(service) => service.dispose()
+ None => ()
+ }
+}
diff --git a/editor/scripts/build-web.mbtx b/editor/scripts/build-web.mbtx
index e9219f185..d95720aa3 100644
--- a/editor/scripts/build-web.mbtx
+++ b/editor/scripts/build-web.mbtx
@@ -151,7 +151,6 @@ fn css_sources() -> Array[String] {
"viewer/contrib/references/browser/references.css",
"viewer/contrib/hover/hover.css",
"viewer/contrib/quick_diff/browser/quick_diff.css",
- "viewer/browser/unified_diff/unified_diff.css",
"viewer/contrib/agent_feedback/browser/agent_feedback.css",
"viewer/browser/diagram_viewport/diagram_viewport.css",
"viewer/contrib/markdown_comments/browser/markdown_comments.css",
diff --git a/editor/tests/browser/component/definition_navigation.spec.js b/editor/tests/browser/component/definition_navigation.spec.js
index 58aeb23ac..b86fd7f47 100644
--- a/editor/tests/browser/component/definition_navigation.spec.js
+++ b/editor/tests/browser/component/definition_navigation.spec.js
@@ -13,12 +13,16 @@ const preview =
'.monaco-editor.readonly-editor';
const markdownEditor =
'.definition-markdown-host > .moonbit-viewer-markdown-document';
+const markdownArticle =
+ `${markdownEditor} .moonbit-viewer-markdown-document-article`;
const markdownPeek =
`${markdownEditor} > .moonbit-viewer-markdown-document-overlays > ` +
'.moonbit-viewer-references-peek-overlay';
const markdownPreview =
`${markdownPeek} .moonbit-viewer-references-peek-preview > ` +
- '.moonbit-viewer-markdown-document';
+ '.monaco-editor.readonly-editor';
+const markdownDefinitionLink =
+ `${markdownEditor} .moonbit-viewer-markdown-definition-link`;
const contextMenu =
'body > .moonbit-context-menu:not(.moonbit-context-submenu)';
const goToDefinitionAction =
@@ -27,6 +31,9 @@ const goToDefinitionAction =
const peekDefinitionAction =
`${contextMenu} ` +
'[data-context-menu-command="editor.action.peekDefinition"]';
+const goToReferencesAction =
+ `${contextMenu} ` +
+ '[data-context-menu-command="editor.action.goToReferences"]';
const peekReferencesAction =
`${contextMenu} ` +
'[data-context-menu-command="editor.action.referenceSearch.trigger"]';
@@ -59,6 +66,13 @@ async function state(page) {
return page.evaluate(() => globalThis.__definitionControls.state());
}
+async function control(page, method) {
+ await page.evaluate(
+ (name) => globalThis.__definitionControls[name](),
+ method,
+ );
+}
+
async function resetScroll(page) {
await page.evaluate(() => globalThis.__definitionControls.reset_scroll());
await settle(page);
@@ -207,7 +221,7 @@ test('HTML context menu preserves an enclosing selection and runs Go to Definiti
await expect(page.locator(contextMenu)).toHaveCount(1);
await expect(page.locator(`${contextMenu} [role="menu"]`)).toHaveCount(1);
await expect(page.locator(`${contextMenu} [role="menuitem"]`)).toHaveCount(
- 3,
+ 4,
);
await expect(page.locator(goToDefinitionAction)).toContainText(
'Go to Definition',
@@ -215,6 +229,9 @@ test('HTML context menu preserves an enclosing selection and runs Go to Definiti
await expect(page.locator(peekDefinitionAction)).toContainText(
'Peek Definition',
);
+ await expect(page.locator(goToReferencesAction)).toContainText(
+ 'Go to References',
+ );
await expect(page.locator(peekReferencesAction)).toContainText(
'Peek References',
);
@@ -455,7 +472,69 @@ test('F4 replaces a multi-definition preview without losing preview focus', asyn
}
});
-test('Alt+F12 from semantic Markdown mounts a projection-scoped Peek overlay and restores focus', async ({
+test('F12 from semantic Markdown navigates the original source model', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ const beforeCalls = (await state(page)).markdownProviderCalls;
+ const point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('F12');
+ await expect
+ .poll(async () => (await state(page)).markdownProviderCalls)
+ .toBe(beforeCalls + 1);
+ await expect(page.locator(markdownPeek)).toHaveCount(0);
+ await expect(page.locator(markdownPreview)).toHaveCount(0);
+ await expect
+ .poll(() =>
+ page.locator(markdownEditor).evaluate((root) =>
+ root.querySelector(
+ '.moonbit-viewer-markdown-document-viewport',
+ ).scrollTop,
+ ),
+ )
+ .toBeGreaterThan(0);
+
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('F12 from semantic Markdown delegates a cross-model location to the opener', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'enable_markdown_cross_definition');
+ await control(page, 'clear_opened_locations');
+ const point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('F12');
+ await expect
+ .poll(async () => (await state(page)).openedUris)
+ .toHaveLength(1);
+ const snapshot = await state(page);
+ expect(snapshot.openedUris[0]).toContain('/definition-remote.mbt');
+ expect(snapshot.openedModes).toEqual(['Current']);
+ expect(snapshot.resolverCalls).toBe(0);
+ await expect(page.locator(markdownPeek)).toHaveCount(0);
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('Alt+F12 mounts a Markdown overlay with an injected CodeEditor preview and restores focus', async ({
page,
}, testInfo) => {
const reporter = await mountDefinitionFixture(page, testInfo);
@@ -469,16 +548,21 @@ test('Alt+F12 from semantic Markdown mounts a projection-scoped Peek overlay and
await page.mouse.click(point.x, point.y);
await page.keyboard.press('Alt+F12');
await expect(page.locator(markdownPeek)).toHaveCount(1);
+ await expect(page.locator(markdownPeek)).toHaveAttribute(
+ 'aria-label',
+ 'Peek Definition',
+ );
await expect(page.locator(markdownPreview)).toHaveCount(1);
+ await expect(page.locator(markdownPreview)).toContainText(
+ 'definition_alpha',
+ );
await page.locator('.definition-markdown-host').evaluate((host) => {
host.style.width = '640px';
});
await settle(page);
const geometry = await page.locator(markdownPeek).evaluate((root) => {
- const editor = root.closest(
- '.moonbit-viewer-markdown-document',
- );
+ const editor = root.closest('.moonbit-viewer-markdown-document');
const rootRect = root.getBoundingClientRect();
const editorRect = editor.getBoundingClientRect();
return {
@@ -522,10 +606,363 @@ test('Alt+F12 from semantic Markdown mounts a projection-scoped Peek overlay and
await page.mouse.click(freshPoint.x, freshPoint.y);
await page.keyboard.press('Alt+F12');
await expect(page.locator(markdownPeek)).toHaveCount(1);
- await page.evaluate(
- () => globalThis.__definitionControls.replace_markdown_source(),
+ await page.locator(markdownEditor).evaluate((root) => {
+ globalThis.__definitionRetainedMarkdownRoot = root;
+ });
+ await control(page, 'replace_markdown_source');
+ await expect(page.locator(markdownEditor)).toHaveCount(1);
+ expect(
+ await page.evaluate(
+ (selector) =>
+ document.querySelector(selector) ===
+ globalThis.__definitionRetainedMarkdownRoot,
+ markdownEditor,
+ ),
+ ).toBe(true);
+ await expect(page.locator(markdownPeek)).toHaveCount(0);
+
+ const replacementPoint = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(replacementPoint.x, replacementPoint.y);
+ await page.keyboard.press('Alt+F12');
+ await expect(page.locator(markdownPeek)).toHaveCount(1);
+ await control(page, 'replace_markdown_model');
+ await expect(page.locator(markdownPeek)).toHaveCount(0);
+ await expect(page.locator(markdownEditor)).toHaveCount(1);
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('Shift+F12 uses Go to References and keeps the Markdown controller local', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ const before = await state(page);
+ const point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('Shift+F12');
+ await expect(page.locator(markdownPeek)).toHaveCount(1);
+ await expect(page.locator(markdownPeek)).toHaveAttribute(
+ 'aria-label',
+ 'Peek References',
+ );
+ await expect(page.locator(markdownPreview)).toHaveCount(1);
+ await expect
+ .poll(async () => (await state(page)).referencesProviderCalls)
+ .toBe(before.referencesProviderCalls + 1);
+ expect((await state(page)).markdownProviderCalls).toBe(
+ before.markdownProviderCalls,
+ );
+ await expect(page.locator(peek)).toHaveCount(0);
+ await page.keyboard.press('Escape');
+ await expect(page.locator(markdownPeek)).toHaveCount(0);
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('multiple Markdown definitions query once and default to Peek', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'enable_multiple_markdown_definitions');
+ const beforeCalls = (await state(page)).markdownProviderCalls;
+ const point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('F12');
+ await expect(page.locator(markdownPeek)).toHaveCount(1);
+ await expect(page.locator(`${markdownPeek} [role="treeitem"]`)).toHaveCount(
+ 2,
+ );
+ await expect(page.locator(markdownPreview)).toHaveCount(1);
+ expect((await state(page)).markdownProviderCalls).toBe(beforeCalls + 1);
+ await page.keyboard.press('Escape');
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('Markdown right-click commands and modifier-click use the shared actions', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ let point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ expect(
+ await contextMenuDefaultPrevented(page, markdownArticle, () =>
+ page.mouse.click(point.x, point.y, { button: 'right' }),
+ ),
+ ).toBe(true);
+ await expect(page.locator(`${contextMenu} [role="menuitem"]`)).toHaveCount(
+ 4,
+ );
+ await expect(page.locator(goToDefinitionAction)).toContainText(
+ 'Go to Definition',
+ );
+ await expect(page.locator(peekDefinitionAction)).toContainText(
+ 'Peek Definition',
+ );
+ await expect(page.locator(goToReferencesAction)).toContainText(
+ 'Go to References',
+ );
+ await expect(page.locator(peekReferencesAction)).toContainText(
+ 'Peek References',
+ );
+ const referenceCalls = (await state(page)).referencesProviderCalls;
+ await page.locator(goToReferencesAction).click();
+ await expect(page.locator(markdownPeek)).toHaveAttribute(
+ 'aria-label',
+ 'Peek References',
+ );
+ expect((await state(page)).referencesProviderCalls).toBe(
+ referenceCalls + 1,
+ );
+ await page.keyboard.press('Escape');
+
+ point = await markdownTextRange(page, 'definition_alpha', true, 0);
+ await page.mouse.move(2, 2);
+ await page.keyboard.down(platformModifier);
+ await page.mouse.move(point.x, point.y);
+ await expect(page.locator(markdownDefinitionLink)).not.toHaveCount(0);
+ const definitionCalls = (await state(page)).markdownProviderCalls;
+ await page.mouse.down();
+ await page.mouse.up();
+ await expect
+ .poll(async () => (await state(page)).markdownProviderCalls)
+ .toBe(definitionCalls + 1);
+ await expect(page.locator(markdownDefinitionLink)).toHaveCount(0);
+ await page.keyboard.up(platformModifier);
+ } finally {
+ await page.keyboard.up(platformModifier).catch(() => {});
+ reporter.dispose();
+ }
+});
+
+test('invalid cross-model resolver output is released and leaves Peek unavailable', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'enable_markdown_cross_definition');
+ await control(page, 'use_wrong_resolver_model');
+ const point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('Alt+F12');
+ await expect(page.locator(markdownPeek)).toHaveCount(1);
+ await expect(
+ page.locator(`${markdownPeek} .moonbit-viewer-references-peek-status`),
+ ).toContainText('No preview available');
+ await expect
+ .poll(async () => {
+ const snapshot = await state(page);
+ return [snapshot.resolverAcquires, snapshot.resolverReleases];
+ })
+ .toEqual([2, 2]);
+ await expect(page.locator(markdownPreview)).toHaveCount(0);
+ await page.keyboard.press('Escape');
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('missing cross-model previews stay unavailable without acquiring leases', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'enable_markdown_cross_definition');
+ await control(page, 'use_missing_resolver');
+ let point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('Alt+F12');
+ await expect(
+ page.locator(`${markdownPeek} .moonbit-viewer-references-peek-status`),
+ ).toContainText('No preview available');
+ await expect
+ .poll(async () => (await state(page)).resolverCalls)
+ .toBe(2);
+ let snapshot = await state(page);
+ expect(snapshot.resolverAcquires).toBe(0);
+ expect(snapshot.resolverReleases).toBe(0);
+ await page.keyboard.press('Escape');
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('disposed cross-model previews release every rejected resolver lease', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'enable_markdown_cross_definition');
+ await control(page, 'dispose_remote_model');
+ const point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
);
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('Alt+F12');
+ await expect(
+ page.locator(`${markdownPeek} .moonbit-viewer-references-peek-status`),
+ ).toContainText('No preview available');
+ await expect
+ .poll(async () => {
+ const snapshot = await state(page);
+ return [snapshot.resolverAcquires, snapshot.resolverReleases];
+ })
+ .toEqual([2, 2]);
+ await page.keyboard.press('Escape');
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('Code and Markdown Peek controllers keep simultaneous sessions isolated', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'enable_multiple_definitions');
+ const codePoint = await referencePoint(page);
+ await page.mouse.click(codePoint.x, codePoint.y);
+ await page.keyboard.press('Alt+F12');
+ await expect(page.locator(peek)).toHaveCount(1);
+
+ const markdownPoint = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(markdownPoint.x, markdownPoint.y);
+ await page.keyboard.press('Alt+F12');
+ await expect(page.locator(markdownPeek)).toHaveCount(1);
+ await expect(page.locator(peek)).toHaveCount(1);
+
+ await page.keyboard.press('Escape');
await expect(page.locator(markdownPeek)).toHaveCount(0);
+ await expect(page.locator(peek)).toHaveCount(1);
+ await page.locator(preview).focus();
+ await page.keyboard.press('Escape');
+ await expect(page.locator(peek)).toHaveCount(0);
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('a newer Markdown request cancels a pending predecessor before one cross-model open', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'enable_markdown_cross_definition');
+ await control(page, 'delay_markdown_provider');
+ const point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ const beforeCalls = (await state(page)).markdownProviderCalls;
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('F12');
+ await expect
+ .poll(async () => (await state(page)).markdownProviderCalls)
+ .toBe(beforeCalls + 1);
+ await page.keyboard.press('F12');
+ await expect
+ .poll(async () => (await state(page)).markdownProviderCalls)
+ .toBe(beforeCalls + 2);
+ await control(page, 'release_markdown_provider');
+ await expect
+ .poll(async () => (await state(page)).openedUris)
+ .toHaveLength(1);
+ const snapshot = await state(page);
+ expect(snapshot.openedUris[0]).toContain('/definition-remote.mbt');
+ expect(snapshot.openedModes).toEqual(['Current']);
+ } finally {
+ reporter.dispose();
+ }
+});
+
+test('projection replacement cancels a pending Markdown provider and rebinds navigation', async ({
+ page,
+}, testInfo) => {
+ const reporter = await mountDefinitionFixture(page, testInfo);
+ try {
+ await control(page, 'delay_markdown_provider');
+ let point = await markdownTextRange(
+ page,
+ 'definition_alpha',
+ true,
+ 0,
+ );
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('F12');
+ await expect
+ .poll(async () => (await state(page)).markdownProviderPending)
+ .toBe(true);
+ await control(page, 'replace_markdown_source');
+ await expect(page.locator(markdownPeek)).toHaveCount(0);
+ await control(page, 'release_markdown_provider');
+ await expect
+ .poll(async () => (await state(page)).markdownProviderPending)
+ .toBe(false);
+ await settle(page);
+ expect((await state(page)).openedUris).toEqual([]);
+ await expect(page.locator(markdownPeek)).toHaveCount(0);
+
+ point = await markdownTextRange(page, 'definition_alpha', true, 0);
+ const beforeCalls = (await state(page)).markdownProviderCalls;
+ await page.mouse.click(point.x, point.y);
+ await page.keyboard.press('F12');
+ await expect
+ .poll(async () => (await state(page)).markdownProviderCalls)
+ .toBe(beforeCalls + 1);
+ await expect
+ .poll(() =>
+ page.locator(markdownEditor).evaluate((root) =>
+ root.querySelector(
+ '.moonbit-viewer-markdown-document-viewport',
+ ).scrollTop,
+ ),
+ )
+ .toBeGreaterThan(0);
} finally {
reporter.dispose();
}
diff --git a/editor/tests/browser/component/diff_editor_lifecycle.spec.js b/editor/tests/browser/component/diff_editor_lifecycle.spec.js
new file mode 100644
index 000000000..a18843af6
--- /dev/null
+++ b/editor/tests/browser/component/diff_editor_lifecycle.spec.js
@@ -0,0 +1,90 @@
+import { expect, test } from '../support/test.js';
+
+test('owns and tears down exactly two kernels and one shared diff view model', async ({ page }) => {
+ await page.goto('/browser-tests/component.html?diffLifecycle=1');
+ await page.waitForFunction(() => Boolean(globalThis.__diffEditorLifecycleControls));
+
+ const root = page.locator('.diff-lifecycle-host > .moonbit-diff-editor');
+ await expect(root).toHaveCount(1);
+ await expect(root.locator('.moonbit-diff-editor-pane > .monaco-editor')).toHaveCount(2);
+ await expect(
+ root.locator(
+ '.diff-editor-inline-deleted-block[data-virtualized-render-lines="true"]',
+ ),
+ ).toHaveCount(1);
+
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ globalThis.__diffEditorLifecycleControls.snapshot(),
+ ),
+ )
+ .toMatchObject({
+ kernelCount: 2,
+ kernelsAreDistinct: true,
+ viewModelCount: 1,
+ widgetDisposed: false,
+ disposePassCount: 0,
+ originalKernelDisposed: false,
+ modifiedKernelDisposed: false,
+ viewModelDisposed: false,
+ modelPairIsCommitting: false,
+ committedModelGeneration: 1,
+ overviewRulerPresent: true,
+ overviewRulerDisposed: false,
+ inlineRenderContextPresent: true,
+ inlineRenderContextDisposed: false,
+ resizeObserverPresent: true,
+ resizeObserverDisposed: false,
+ afterRenderWaiterCount: 0,
+ afterRenderRemaining: 0,
+ viewModelPendingSchedulerCount: 0,
+ });
+
+ const before = await page.evaluate(() =>
+ globalThis.__diffEditorLifecycleControls.snapshot(),
+ );
+ expect(before.originalKernelId).not.toBe(before.modifiedKernelId);
+ expect(before.inlineRenderLiveHandleCount).toBeGreaterThan(0);
+ expect(before.inlineRenderRegisteredZoneCount).toBeGreaterThan(0);
+
+ await page.evaluate(() => globalThis.__diffEditorLifecycleControls.dispose());
+ await expect(root).toHaveCount(0);
+ await expect(page.locator('.diff-lifecycle-host .monaco-editor')).toHaveCount(0);
+
+ const afterFirstDispose = await page.evaluate(() =>
+ globalThis.__diffEditorLifecycleControls.snapshot(),
+ );
+ expect(afterFirstDispose).toMatchObject({
+ kernelCount: 2,
+ kernelsAreDistinct: true,
+ viewModelCount: 1,
+ widgetDisposed: true,
+ disposePassCount: 1,
+ originalKernelDisposed: true,
+ modifiedKernelDisposed: true,
+ viewModelDisposed: true,
+ modelPairIsCommitting: false,
+ overviewRulerPresent: false,
+ overviewRulerDisposed: true,
+ inlineRenderContextPresent: false,
+ inlineRenderContextDisposed: true,
+ inlineRenderLiveHandleCount: 0,
+ inlineRenderRegisteredZoneCount: 0,
+ resizeObserverPresent: false,
+ resizeObserverDisposed: true,
+ afterRenderWaiterCount: 0,
+ afterRenderRemaining: 0,
+ viewModelPendingSchedulerCount: 0,
+ lifetimeSubscriptionCount: 0,
+ });
+
+ await page.evaluate(() => globalThis.__diffEditorLifecycleControls.dispose());
+ const afterSecondDispose = await page.evaluate(() =>
+ globalThis.__diffEditorLifecycleControls.snapshot(),
+ );
+ expect(afterSecondDispose).toEqual(afterFirstDispose);
+ await page.evaluate(() =>
+ globalThis.__diffEditorLifecycleControls.dispose_models(),
+ );
+});
diff --git a/editor/tests/browser/moonbit/component/diff_editor_lifecycle_scenario.mbt b/editor/tests/browser/moonbit/component/diff_editor_lifecycle_scenario.mbt
new file mode 100644
index 000000000..bbe9f4799
--- /dev/null
+++ b/editor/tests/browser/moonbit/component/diff_editor_lifecycle_scenario.mbt
@@ -0,0 +1,95 @@
+///|
+fn diff_editor_lifecycle_model(
+ name : String,
+ content : String,
+) -> @model.TextModel {
+ let uri = @base_common.Uri::from(
+ scheme="memory",
+ authority="diff-lifecycle",
+ path="/\{name}.mbt",
+ ) catch {
+ _ => abort("invalid lifecycle fixture URI")
+ }
+ TextModel(uri, "\{name}.mbt", "moonbit", 1, "diff-lifecycle-\{name}", content)
+}
+
+///|
+fn diff_editor_lifecycle_snapshot_json(
+ widget : @diff_editor.DiffEditorWidget,
+) -> String {
+ let snapshot = widget.lifecycle_snapshot()
+ Json::object({
+ "kernelCount": snapshot.kernel_count.to_json(),
+ "originalKernelId": snapshot.original_kernel_id.to_json(),
+ "modifiedKernelId": snapshot.modified_kernel_id.to_json(),
+ "kernelsAreDistinct": snapshot.kernels_are_distinct.to_json(),
+ "viewModelCount": snapshot.view_model_count.to_json(),
+ "widgetDisposed": snapshot.widget_disposed.to_json(),
+ "disposePassCount": snapshot.dispose_pass_count.to_json(),
+ "originalKernelDisposed": snapshot.original_kernel_disposed.to_json(),
+ "modifiedKernelDisposed": snapshot.modified_kernel_disposed.to_json(),
+ "viewModelDisposed": snapshot.view_model_disposed.to_json(),
+ "modelPairIsCommitting": snapshot.model_pair_is_committing.to_json(),
+ "committedModelGeneration": snapshot.committed_model_generation.to_json(),
+ "overviewRulerPresent": snapshot.overview_ruler_present.to_json(),
+ "overviewRulerDisposed": snapshot.overview_ruler_disposed.to_json(),
+ "inlineRenderContextPresent": snapshot.inline_render_context_present.to_json(),
+ "inlineRenderContextDisposed": snapshot.inline_render_context_disposed.to_json(),
+ "inlineRenderLiveHandleCount": snapshot.inline_render_live_handle_count.to_json(),
+ "inlineRenderRegisteredZoneCount": snapshot.inline_render_registered_zone_count.to_json(),
+ "resizeObserverPresent": snapshot.resize_observer_present.to_json(),
+ "resizeObserverDisposed": snapshot.resize_observer_disposed.to_json(),
+ "afterRenderWaiterCount": snapshot.after_render_waiter_count.to_json(),
+ "afterRenderRemaining": snapshot.after_render_remaining.to_json(),
+ "viewModelPendingSchedulerCount": snapshot.view_model_pending_scheduler_count.to_json(),
+ "lifetimeSubscriptionCount": snapshot.lifetime_subscription_count.to_json(),
+ }).stringify()
+}
+
+///|
+fn run_diff_editor_lifecycle_test() -> Unit {
+ let document = @rdom.document()
+ guard document.query_selector("#app").to_option() is Some(app) else {
+ abort("missing #app mount point")
+ }
+ let host = document.create_element("div")
+ host.set_attribute("class", "diff-lifecycle-host")
+ host.set_attribute("style", "width:700px;height:420px;")
+ app.append_child(host.as_node())
+ let provider = @diff.CoreDocumentDiffProvider()
+ let provider_object : &@diff.DocumentDiffProvider = provider
+ let widget = @diff_editor.DiffEditorWidget::create(
+ host,
+ provider_object,
+ editor_options=@code_widget.CodeEditorOptions::default(),
+ options=DiffEditorOptions(layout=Inline, automatic_inline=false),
+ )
+ let original = diff_editor_lifecycle_model(
+ "original",
+ "deleted line\n".repeat(260) + "shared tail",
+ )
+ let modified = diff_editor_lifecycle_model("modified", "shared tail")
+ widget.set_model(Some({ original, modified })) |> ignore
+ install_diff_editor_lifecycle_controls(
+ () => diff_editor_lifecycle_snapshot_json(widget),
+ () => widget.dispose(),
+ () => {
+ original.dispose()
+ modified.dispose()
+ },
+ )
+}
+
+///|
+extern "js" fn install_diff_editor_lifecycle_controls(
+ snapshot : () -> String,
+ dispose : () -> Unit,
+ dispose_models : () -> Unit,
+) -> Unit =
+ #| (snapshot, dispose, disposeModels) => {
+ #| globalThis.__diffEditorLifecycleControls = Object.freeze({
+ #| snapshot() { return JSON.parse(snapshot()); },
+ #| dispose,
+ #| dispose_models: disposeModels,
+ #| });
+ #| }
diff --git a/editor/tests/browser/moonbit/component/main.mbt b/editor/tests/browser/moonbit/component/main.mbt
index 6f708a14d..fdbc16afd 100644
--- a/editor/tests/browser/moonbit/component/main.mbt
+++ b/editor/tests/browser/moonbit/component/main.mbt
@@ -1,6 +1,8 @@
///|
fn main {
- if initial_size_requested() {
+ if diff_editor_lifecycle_requested() {
+ run_diff_editor_lifecycle_test()
+ } else if initial_size_requested() {
run_initial_size_test()
} else if markdown_document_requested() {
run_markdown_document_test()
@@ -23,6 +25,10 @@ fn main {
}
}
+///|
+extern "js" fn diff_editor_lifecycle_requested() -> Bool =
+ #| () => new URLSearchParams(location.search).has('diffLifecycle')
+
///|
extern "js" fn markdown_document_requested() -> Bool =
#| () => new URLSearchParams(location.search).has('markdownDocument')
diff --git a/editor/tests/browser/moonbit/component/markdown_document_scenario.mbt b/editor/tests/browser/moonbit/component/markdown_document_scenario.mbt
index 37093ca01..3f1c2960b 100644
--- a/editor/tests/browser/moonbit/component/markdown_document_scenario.mbt
+++ b/editor/tests/browser/moonbit/component/markdown_document_scenario.mbt
@@ -272,7 +272,7 @@ fn markdown_document_replacement_source() -> String {
#|```
#|
#|The replacement stays long enough to retain an observable scroll state
- #|while the Viewer swaps between Markdown and code presentations.
+ #|while the host swaps between MarkdownViewer and Viewer surfaces.
)
}
@@ -354,42 +354,73 @@ fn run_markdown_document_test() -> Unit {
set_markdown_document_diagnostic(
markers, markdown_model, "markdown_answer", "initial markdown diagnostic",
)
- let viewer = @viewer.Viewer::create(
+ let viewer = @viewer.MarkdownViewer::create(
host,
services~,
- options=ViewerOptions(theme="dark"),
+ options=MarkdownViewerOptions(theme="dark"),
)
- let secondary_viewer = @viewer.Viewer::create(
+ let secondary_viewer = @viewer.MarkdownViewer::create(
secondary_host,
services~,
- options=ViewerOptions(theme="dark"),
+ options=MarkdownViewerOptions(theme="dark"),
)
- viewer.set_model(Some(markdown_model))
- secondary_viewer.set_model(Some(markdown_model))
+ viewer.set_model(Some(MarkdownViewerModel(markdown_model, MoonBitMarkdown)))
+ |> ignore
+ secondary_viewer.set_model(
+ Some(MarkdownViewerModel(markdown_model, MoonBitMarkdown)),
+ )
+ |> ignore
viewer.handle_initialized()
secondary_viewer.handle_initialized()
+ let code_viewer : Ref[@viewer.Viewer?] = Ref(None)
install_markdown_document_controls(
() => markdown_model.get_value(),
- () => viewer.set_value(markdown_document_diagram_source()),
+ () => markdown_model.set_value(markdown_document_diagram_source()),
() => {
- viewer.set_value(markdown_document_replacement_source())
+ markdown_model.set_value(markdown_document_replacement_source())
set_markdown_document_diagnostic(
markers, markdown_model, "replacement_answer", "replacement markdown diagnostic",
)
},
theme => viewer.update_options(viewer.get_options().with_theme(theme)),
- () => viewer.set_model(Some(code_model)),
- () => viewer.set_model(Some(markdown_model)),
+ () => {
+ viewer.set_model(None) |> ignore
+ let code = @viewer.Viewer::create(
+ host,
+ services~,
+ options=ViewerOptions(theme="dark"),
+ )
+ code.set_model(Some(code_model))
+ code.handle_initialized()
+ code_viewer.val = Some(code)
+ },
+ () => {
+ match code_viewer.val {
+ Some(code) => {
+ code.dispose()
+ code_viewer.val = None
+ }
+ None => ()
+ }
+ viewer.set_model(
+ Some(MarkdownViewerModel(markdown_model, MoonBitMarkdown)),
+ )
+ |> ignore
+ },
() => {
set_markdown_document_diagnostic(
markers, same_uri_model, "same_uri_answer", "same URI markdown diagnostic",
)
- viewer.set_model(Some(same_uri_model))
+ viewer.set_model(
+ Some(MarkdownViewerModel(same_uri_model, MoonBitMarkdown)),
+ )
+ |> ignore
},
() => hover_harness.calls_json(),
(call_id, outcome) => hover_harness.release(call_id, outcome),
message => {
- if viewer.get_model() is Some(model) {
+ if viewer.get_model() is Some(viewer_model) {
+ let model = viewer_model.model
let symbol = if model.get_value().contains("replacement_answer") {
"replacement_answer"
} else if model.get_value().contains("same_uri_answer") {
@@ -402,14 +433,22 @@ fn run_markdown_document_test() -> Unit {
},
() => set_markdown_document_marker_policy_fixture(markers, markdown_model),
show_unused => {
- viewer.update_options(ViewerOptions(theme="dark", show_unused~))
+ viewer.update_options(MarkdownViewerOptions(theme="dark", show_unused~))
},
- top => viewer.set_scroll_top(top),
+ top => set_markdown_document_scroll_top(top),
width => {
host.set_attribute("style", "height:220px;width:\{width}px;")
viewer.layout()
+ match code_viewer.val {
+ Some(code) => code.layout()
+ None => ()
+ }
},
() => {
+ match code_viewer.val {
+ Some(code) => code.dispose()
+ None => ()
+ }
viewer.dispose()
secondary_viewer.dispose()
services.dispose()
@@ -424,6 +463,15 @@ fn run_markdown_document_test() -> Unit {
poll_markdown_document_ready(ctx, markdown_model, 100)
}
+///|
+extern "js" fn set_markdown_document_scroll_top(top : Double) -> Unit =
+ #| (top) => {
+ #| const viewport = document.querySelector(
+ #| '.markdown-document-host > .moonbit-viewer-markdown-document > .moonbit-viewer-markdown-document-viewport',
+ #| );
+ #| if (viewport) viewport.scrollTop = top;
+ #| }
+
///|
fn poll_markdown_document_ready(
ctx : @support.BrowserTestContext,
diff --git a/editor/tests/browser/moonbit/component/markdown_folding_scenario.mbt b/editor/tests/browser/moonbit/component/markdown_folding_scenario.mbt
index acc599c62..f7d9d9301 100644
--- a/editor/tests/browser/moonbit/component/markdown_folding_scenario.mbt
+++ b/editor/tests/browser/moonbit/component/markdown_folding_scenario.mbt
@@ -1,4 +1,4 @@
-// Direct public-Viewer proof for Markdown section folding. The mounted
+// Direct public-MarkdownViewer proof for Markdown section folding. The mounted
// white-box suite pins the synchronous fold state machine; this scenario owns
// what only a real browser can observe: computed `display:none`, real toggle
// clicks, caret-based semantic hover against visible and revealed fences, and
@@ -46,7 +46,7 @@ fn markdown_folding_source() -> String {
///|
fn markdown_folding_facts_json(
- viewer : @viewer.Viewer,
+ viewer : @viewer.MarkdownViewer,
source : () -> String,
) -> String {
let current = source()
@@ -61,7 +61,7 @@ fn markdown_folding_facts_json(
"beta": offset_of("## Beta"),
"deep": offset_of("### Deep"),
"collapsed": viewer
- .collapsed_markdown_sections()
+ .collapsed_sections()
.map(offset => offset.to_json())
.to_json(),
}).stringify()
@@ -112,12 +112,12 @@ fn run_markdown_folding_test() -> Unit {
"markdown-folding-rev-1",
markdown_folding_source(),
)
- let viewer = @viewer.Viewer::create(
+ let viewer = @viewer.MarkdownViewer::create(
host,
services~,
- options=ViewerOptions(theme="dark"),
+ options=MarkdownViewerOptions(theme="dark"),
)
- viewer.set_model(Some(model))
+ viewer.set_model(Some(MarkdownViewerModel(model, MoonBitMarkdown))) |> ignore
viewer.handle_initialized()
install_markdown_folding_controls(
() => markdown_folding_facts_json(viewer, () => model.get_value()),
@@ -126,12 +126,9 @@ fn run_markdown_folding_test() -> Unit {
)
// The initial seeded state is part of the report so the spec separates
// "policy seeded wrongly" from "a later interaction broke it".
- ctx.metric_int(
- "initialCollapsedCount",
- viewer.collapsed_markdown_sections().length(),
- )
+ ctx.metric_int("initialCollapsedCount", viewer.collapsed_sections().length())
ctx.assert_true(
- viewer.collapsed_markdown_sections().length() == 1,
+ viewer.collapsed_sections().length() == 1,
"auto-fold seeds exactly the bulky depth-3 section",
)
ctx.finish()
diff --git a/editor/tests/browser/moonbit/component/moon.pkg b/editor/tests/browser/moonbit/component/moon.pkg
index 1cfc0f103..8c8b3ad8e 100644
--- a/editor/tests/browser/moonbit/component/moon.pkg
+++ b/editor/tests/browser/moonbit/component/moon.pkg
@@ -4,7 +4,10 @@ import {
"moonbitlang/editor/platform/log",
"moonbitlang/editor/viewer",
"moonbitlang/editor/internal/viewer/browser/testing" @viewer_testing,
+ "moonbitlang/editor/internal/viewer/code_editor_widget" @code_widget,
+ "moonbitlang/editor/internal/viewer/diff_editor",
"moonbitlang/editor/viewer/common/editor_api",
+ "moonbitlang/editor/viewer/common/diff",
"moonbitlang/editor/viewer/common/languages",
"moonbitlang/editor/viewer/common/markers",
"moonbitlang/editor/viewer/common/model",
diff --git a/editor/tests/browser/moonbit/definition/definition_scenario.mbt b/editor/tests/browser/moonbit/definition/definition_scenario.mbt
index e017acd8c..58d7375ed 100644
--- a/editor/tests/browser/moonbit/definition/definition_scenario.mbt
+++ b/editor/tests/browser/moonbit/definition/definition_scenario.mbt
@@ -4,12 +4,45 @@
// public Viewer, isolated language providers, and deterministic readback of
// public editor state.
+///|
+#external
+type BrowserDefinitionGate
+
+///|
+extern "js" fn BrowserDefinitionGate::new() -> BrowserDefinitionGate =
+ #| () => {
+ #| let release;
+ #| const promise = new Promise(resolve => { release = resolve; });
+ #| return { promise, release };
+ #| }
+
+///|
+extern "js" fn BrowserDefinitionGate::promise(
+ self : BrowserDefinitionGate,
+) -> @js.Promise = "gate => gate.promise"
+
+///|
+extern "js" fn BrowserDefinitionGate::release(
+ self : BrowserDefinitionGate,
+) -> Unit = "gate => gate.release()"
+
+///|
+async fn BrowserDefinitionGate::wait(self : BrowserDefinitionGate) -> Unit {
+ self.promise().wait() |> ignore
+}
+
///|
priv struct BrowserDefinitionProvider {
calls : Ref[Int]
+ source_uri : @base_common.Uri
location : @language.Location
alternate_location : @language.Location?
+ redirect_location : @language.Location?
multiple : Ref[Bool]
+ redirect : Ref[Bool]
+ delay : Ref[Bool]
+ pending : Ref[Bool]
+ release : BrowserDefinitionGate?
reference_line : Int
}
@@ -20,14 +53,26 @@ impl @language.DefinitionProvider for BrowserDefinitionProvider with fn provide_
position,
_token,
) {
- self.calls.val += 1
- if model.uri.to_string() == self.location.uri.to_string() &&
+ if model.uri.to_string() == self.source_uri.to_string() &&
position.line_number == self.reference_line &&
position.column >= definition_reference_start_column() &&
position.column <= definition_reference_end_column() {
+ self.calls.val += 1
+ if self.delay.val {
+ self.pending.val = true
+ match self.release {
+ Some(release) => release.wait() catch { _ => () }
+ None => ()
+ }
+ self.pending.val = false
+ }
+ let primary = match (self.redirect.val, self.redirect_location) {
+ (true, Some(location)) => location
+ _ => self.location
+ }
match (self.multiple.val, self.alternate_location) {
- (true, Some(alternate)) => [self.location, alternate]
- _ => [self.location]
+ (true, Some(alternate)) => [primary, alternate]
+ _ => [primary]
}
} else {
[]
@@ -48,11 +93,11 @@ impl @language.ReferencesProvider for BrowserReferencesProvider with fn provide_
position,
_token,
) {
- self.calls.val += 1
if model.uri.to_string() == self.code_uri.to_string() &&
position.line_number == 2 &&
position.column >= definition_reference_start_column() &&
position.column <= definition_reference_end_column() {
+ self.calls.val += 1
[
{ uri: self.code_uri, range: Range(1, 5, 1, 21) },
{ uri: self.code_uri, range: Range(2, 11, 2, 27) },
@@ -61,6 +106,7 @@ impl @language.ReferencesProvider for BrowserReferencesProvider with fn provide_
position.line_number == 2 &&
position.column >= definition_reference_start_column() &&
position.column <= definition_reference_end_column() {
+ self.calls.val += 1
[
{ uri: self.markdown_uri, range: Range(2, 11, 2, 27) },
{
@@ -78,6 +124,26 @@ impl @language.ReferencesProvider for BrowserReferencesProvider with fn provide_
}
}
+///|
+priv struct BrowserDefinitionFixtureState {
+ mut resolver_calls : Int
+ mut resolver_acquires : Int
+ mut resolver_releases : Int
+ mut resolver_behavior : Int
+ opened_uris : Array[String]
+ opened_modes : Array[String]
+}
+
+///|
+fn browser_definition_open_mode(
+ mode : @navigation_api.OpenLocationMode,
+) -> String {
+ match mode {
+ Current => "Current"
+ Side => "Side"
+ }
+}
+
///|
fn definition_reference_start_column() -> Int {
11
@@ -121,7 +187,10 @@ fn markdown_definition_line() -> Int {
fn definition_state_json(
viewer : @viewer.Viewer,
provider_calls : Ref[Int],
+ markdown_provider_calls : Ref[Int],
references_provider_calls : Ref[Int],
+ markdown_provider_pending : Ref[Bool],
+ fixture : BrowserDefinitionFixtureState,
) -> String {
let (line, column) = match viewer.get_position() {
Some(position) => (position.line_number, position.column)
@@ -148,7 +217,14 @@ fn definition_state_json(
"activeColumn": active_column.to_json(),
}),
"providerCalls": provider_calls.val.to_json(),
+ "markdownProviderCalls": markdown_provider_calls.val.to_json(),
"referencesProviderCalls": references_provider_calls.val.to_json(),
+ "markdownProviderPending": markdown_provider_pending.val.to_json(),
+ "resolverCalls": fixture.resolver_calls.to_json(),
+ "resolverAcquires": fixture.resolver_acquires.to_json(),
+ "resolverReleases": fixture.resolver_releases.to_json(),
+ "openedUris": fixture.opened_uris.to_json(),
+ "openedModes": fixture.opened_modes.to_json(),
"scrollTop": viewer.get_scroll_top().to_json(),
"scrollHeight": viewer.get_scroll_height().to_json(),
"hasTextFocus": viewer.has_text_focus().to_json(),
@@ -197,9 +273,15 @@ fn run_definition_browser_test() -> Unit {
let provider_multiple = Ref(false)
let provider = BrowserDefinitionProvider::{
calls: provider_calls,
+ source_uri: uri,
location: { uri, range: Range(1, 5, 1, 21) },
alternate_location: Some({ uri, range: Range(3, 1, 3, 14) }),
+ redirect_location: None,
multiple: provider_multiple,
+ redirect: Ref(false),
+ delay: Ref(false),
+ pending: Ref(false),
+ release: None,
reference_line: 2,
}
let markdown_uri = try! @base_common.Uri::from(
@@ -215,10 +297,40 @@ fn run_definition_browser_test() -> Unit {
"definition-markdown-rev-1",
markdown_definition_source(),
)
+ let markdown_replacement_model = @model.TextModel(
+ markdown_uri,
+ "definition-fixture.mbt.md",
+ "moonbit",
+ 1,
+ "definition-markdown-replacement-rev-1",
+ markdown_definition_source(),
+ )
let markdown_provider_calls = Ref(0)
let markdown_provider_multiple = Ref(false)
+ let markdown_provider_redirect = Ref(false)
+ let markdown_provider_delay = Ref(false)
+ let markdown_provider_pending = Ref(false)
+ let markdown_provider_release = BrowserDefinitionGate::new()
+ let remote_uri = try! @base_common.Uri::from(
+ scheme="inmemory",
+ authority="model",
+ path="/definition-remote.mbt",
+ )
+ let remote_model = @model.TextModel(
+ remote_uri, "definition-remote.mbt", "moonbit", 1, "definition-remote-rev-1",
+ "let remote_definition_alpha = 1\nlet remote_use = remote_definition_alpha",
+ )
+ let wrong_uri = try! @base_common.Uri::from(
+ scheme="inmemory",
+ authority="model",
+ path="/definition-wrong.mbt",
+ )
+ let wrong_model = @model.TextModel(
+ wrong_uri, "definition-wrong.mbt", "moonbit", 1, "definition-wrong-rev-1", "let wrong_definition_alpha = 1",
+ )
let markdown_provider = BrowserDefinitionProvider::{
calls: markdown_provider_calls,
+ source_uri: markdown_uri,
location: {
uri: markdown_uri,
range: Range(
@@ -228,8 +340,13 @@ fn run_definition_browser_test() -> Unit {
21,
),
},
- alternate_location: None,
+ alternate_location: Some({ uri: markdown_uri, range: Range(6, 5, 6, 21) }),
+ redirect_location: Some({ uri: remote_uri, range: Range(1, 5, 1, 28) }),
multiple: markdown_provider_multiple,
+ redirect: markdown_provider_redirect,
+ delay: markdown_provider_delay,
+ pending: markdown_provider_pending,
+ release: Some(markdown_provider_release),
reference_line: 2,
}
let references_provider_calls = Ref(0)
@@ -253,8 +370,45 @@ fn run_definition_browser_test() -> Unit {
|> ignore
let log_service = @log.LogService()
let log_handle = log_service.log_handle()
+ let fixture : BrowserDefinitionFixtureState = {
+ resolver_calls: 0,
+ resolver_acquires: 0,
+ resolver_releases: 0,
+ resolver_behavior: 0,
+ opened_uris: [],
+ opened_modes: [],
+ }
+ let resolver = @navigation_api.TextModelResolverHandle(resolve=(
+ resource,
+ _token,
+ ) => {
+ fixture.resolver_calls += 1
+ if resource.to_string() != remote_uri.to_string() ||
+ fixture.resolver_behavior == 1 {
+ None
+ } else {
+ let resolved = if fixture.resolver_behavior == 2 {
+ wrong_model
+ } else {
+ remote_model
+ }
+ fixture.resolver_acquires += 1
+ Some(
+ TextModelReference(resolved, release=() => {
+ fixture.resolver_releases += 1
+ }),
+ )
+ }
+ })
+ let opener = @navigation_api.LocationOpenerHandle(open=request => {
+ fixture.opened_uris.push(request.location.uri.to_string())
+ fixture.opened_modes.push(browser_definition_open_mode(request.mode))
+ true
+ })
let services = @viewer.ViewerServices(
languages=languages.language_handle(log_handle),
+ location_opener=opener,
+ text_model_resolver=resolver,
log_service=log_handle,
)
let viewer = @viewer.Viewer::create(
@@ -269,16 +423,15 @@ fn run_definition_browser_test() -> Unit {
viewer.set_model(Some(model))
viewer.set_position(Position(1, 1))
viewer.handle_initialized()
- let markdown_viewer = @viewer.Viewer::create(
+ let markdown_viewer = @viewer.MarkdownViewer::create(
markdown_host,
services~,
- options=ViewerOptions(
- soft_wrap=false,
- smooth_scrolling=false,
- folding=false,
- ),
+ options=MarkdownViewerOptions(theme="dark"),
+ )
+ markdown_viewer.set_model(
+ Some(MarkdownViewerModel(markdown_model, MoonBitMarkdown)),
)
- markdown_viewer.set_model(Some(markdown_model))
+ |> ignore
markdown_viewer.handle_initialized()
guard viewer.get_dom_node() is Some(root) else {
ctx.fail("mounted Viewer did not expose its root")
@@ -294,11 +447,28 @@ fn run_definition_browser_test() -> Unit {
root,
markdown_root,
() => {
- definition_state_json(viewer, provider_calls, references_provider_calls)
+ definition_state_json(
+ viewer, provider_calls, markdown_provider_calls, references_provider_calls,
+ markdown_provider_pending, fixture,
+ )
},
() => viewer.set_scroll_top(0.0),
() => viewer.set_selection(Selection(Position(2, 11), Position(2, 27))),
() => provider_multiple.val = true,
+ () => markdown_provider_multiple.val = true,
+ () => markdown_provider_redirect.val = true,
+ () => fixture.resolver_behavior = 1,
+ () => fixture.resolver_behavior = 2,
+ () => remote_model.dispose(),
+ () => {
+ fixture.opened_uris.clear()
+ fixture.opened_modes.clear()
+ },
+ () => markdown_provider_delay.val = true,
+ () => {
+ markdown_provider_delay.val = false
+ markdown_provider_release.release()
+ },
() => {
markdown_model.set_value(
(
@@ -308,6 +478,12 @@ fn run_definition_browser_test() -> Unit {
),
)
},
+ () => {
+ markdown_viewer.set_model(
+ Some(MarkdownViewerModel(markdown_replacement_model, MoonBitMarkdown)),
+ )
+ |> ignore
+ },
)
wait_for_definition_fixture(ctx, 100)
}
@@ -348,7 +524,16 @@ extern "js" fn install_definition_controls(
reset_scroll : () -> Unit,
select_reference : () -> Unit,
enable_multiple_definitions : () -> Unit,
+ enable_multiple_markdown_definitions : () -> Unit,
+ enable_markdown_cross_definition : () -> Unit,
+ use_missing_resolver : () -> Unit,
+ use_wrong_resolver_model : () -> Unit,
+ dispose_remote_model : () -> Unit,
+ clear_opened_locations : () -> Unit,
+ delay_markdown_provider : () -> Unit,
+ release_markdown_provider : () -> Unit,
replace_markdown_source : () -> Unit,
+ replace_markdown_model : () -> Unit,
) -> Unit =
#| (
#| outerRoot,
@@ -357,7 +542,16 @@ extern "js" fn install_definition_controls(
#| resetScroll,
#| selectReference,
#| enableMultipleDefinitions,
+ #| enableMultipleMarkdownDefinitions,
+ #| enableMarkdownCrossDefinition,
+ #| useMissingResolver,
+ #| useWrongResolverModel,
+ #| disposeRemoteModel,
+ #| clearOpenedLocations,
+ #| delayMarkdownProvider,
+ #| releaseMarkdownProvider,
#| replaceMarkdownSource,
+ #| replaceMarkdownModel,
#| ) => {
#| globalThis.__definitionControls = {
#| outerRoot,
@@ -366,6 +560,16 @@ extern "js" fn install_definition_controls(
#| reset_scroll: resetScroll,
#| select_reference: selectReference,
#| enable_multiple_definitions: enableMultipleDefinitions,
+ #| enable_multiple_markdown_definitions:
+ #| enableMultipleMarkdownDefinitions,
+ #| enable_markdown_cross_definition: enableMarkdownCrossDefinition,
+ #| use_missing_resolver: useMissingResolver,
+ #| use_wrong_resolver_model: useWrongResolverModel,
+ #| dispose_remote_model: disposeRemoteModel,
+ #| clear_opened_locations: clearOpenedLocations,
+ #| delay_markdown_provider: delayMarkdownProvider,
+ #| release_markdown_provider: releaseMarkdownProvider,
#| replace_markdown_source: replaceMarkdownSource,
+ #| replace_markdown_model: replaceMarkdownModel,
#| };
#| }
diff --git a/editor/tests/browser/moonbit/definition/moon.pkg b/editor/tests/browser/moonbit/definition/moon.pkg
index c8ce3008f..1acef9f68 100644
--- a/editor/tests/browser/moonbit/definition/moon.pkg
+++ b/editor/tests/browser/moonbit/definition/moon.pkg
@@ -7,8 +7,10 @@ import {
"moonbitlang/editor/viewer/common/core",
"moonbitlang/editor/viewer/common/languages",
"moonbitlang/editor/viewer/common/model",
+ "moonbitlang/editor/viewer/common/navigation_api",
"moonbitlang/editor/tests/browser/moonbit/support",
"moonbit-community/rabbita/dom" @rdom,
+ "moonbit-community/rabbita/js",
}
supported_targets = "js"
diff --git a/editor/tests/browser/moonbit/definition/pkg.generated.mbti b/editor/tests/browser/moonbit/definition/pkg.generated.mbti
index 1c150578f..c1e001a90 100644
--- a/editor/tests/browser/moonbit/definition/pkg.generated.mbti
+++ b/editor/tests/browser/moonbit/definition/pkg.generated.mbti
@@ -6,6 +6,7 @@ package "moonbitlang/editor/tests/browser/moonbit/definition"
// Errors
// Types and methods
+type BrowserDefinitionGate
// Type aliases
diff --git a/editor/tests/browser/smoke/embed.spec.js b/editor/tests/browser/smoke/embed.spec.js
index 96a83ed56..a3398c557 100644
--- a/editor/tests/browser/smoke/embed.spec.js
+++ b/editor/tests/browser/smoke/embed.spec.js
@@ -2,25 +2,32 @@ import { expect, test } from '../support/test.js';
import { workspaceItem as workspaceSelector } from '../support/app.js';
const sourceEditor =
- '.viewer-host:not(.diff-viewer-host) > .monaco-editor.readonly-editor';
-
-// Proves the library boundary: the embedded page runs the viewer and the
-// file-tree widget against in-memory providers, with no websocket opened.
-test('runs the viewer and tree from in-memory providers without a server', async ({ page }) => {
+ '.code-viewer-host > .monaco-editor.readonly-editor';
+const markdownDocument =
+ '.markdown-viewer-host > .moonbit-viewer-markdown-document';
+const diffEditor = '.diff-editor-host > .moonbit-diff-editor';
+
+// Proves the public embedding boundary: a code-only Viewer, an explicit
+// MarkdownViewer, a first-class DiffEditor, and the file tree all run against
+// in-memory providers without opening a websocket.
+test('runs the independent viewer surfaces and tree without a server', async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
const websockets = [];
page.on('websocket', (ws) => websockets.push(ws.url()));
await page.goto('/embed.html');
-
- // The embedding host auto-opens src/main.mbt; auto-reveal expands src.
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
+ await expect(page.locator('.editor-shell')).toHaveAttribute(
+ 'data-source-presentation',
+ 'code',
+ );
await expect(page.locator(sourceEditor)).toContainText('fn main');
await expect
.poll(async () => (await page.locator('.embedded-viewer-stack').boundingBox())?.width ?? 0)
- .toBeGreaterThan(400);
+ .toBeGreaterThan(900);
- // Real language highlighting with no server: the MoonBit lexer is
- // registered by the embedding host, not fetched from anywhere.
+ // Language highlighting is registered by the embedding host, not fetched
+ // from a workbench or server.
await expect(page.locator('.mtk3', { hasText: 'fn' }).first()).toBeVisible();
await expect(page.locator(workspaceItem('src'))).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator(workspaceItem('src/main.mbt'))).toHaveAttribute(
@@ -28,26 +35,26 @@ test('runs the viewer and tree from in-memory providers without a server', async
'true',
);
- // The same public facade exposes the side-by-side DiffViewer. The host
- // toggles sibling surfaces, preserving the ordinary source Viewer's model
- // and scroll while the comparison owns two ordinary Viewer panes.
- const diffToggle = page.locator('[data-action=\"toggle-diff\"]');
+ const diffToggle = page.locator('[data-action="toggle-diff"]');
const layoutToggle = page.locator('[data-action="toggle-diff-layout"]');
await expect(layoutToggle).toBeDisabled();
- await expect(layoutToggle).toHaveAccessibleName('Unified diff layout');
+ await expect(layoutToggle).toHaveAccessibleName('Inline diff layout');
await expect(diffToggle).toHaveAccessibleName('Full diff');
await diffToggle.click();
- await expect(diffToggle).toHaveAttribute('aria-pressed', 'true');
- await expect(diffToggle).toHaveAccessibleName('Full diff');
- const diff = page.locator('.diff-viewer-host > .moonbit-diff-editor');
- await expect(diff).toBeVisible();
- await expect
- .poll(async () => (await diff.boundingBox())?.width ?? 0)
- .toBeGreaterThan(400);
+
+ const diff = page.locator(diffEditor);
const originalPane = diff.locator('.moonbit-diff-editor-original');
const modifiedPane = diff.locator('.moonbit-diff-editor-modified');
+ await expect(diff).toBeVisible();
+ await expect(diff).toHaveAttribute('role', 'region');
+ await expect(diff).toHaveAccessibleName('File comparison');
+ await expect(diff).toHaveAttribute('data-render-mode', 'side-by-side');
+ await expect(originalPane).toHaveAttribute('role', 'group');
+ await expect(originalPane).toHaveAccessibleName('Original file');
+ await expect(modifiedPane).toHaveAttribute('role', 'group');
+ await expect(modifiedPane).toHaveAccessibleName('Modified file');
await expect(originalPane.locator('.monaco-editor')).toContainText(
- 'println(\"hello\")',
+ 'println("hello")',
);
await expect(modifiedPane.locator('.monaco-editor')).toContainText(
'println(greeting())',
@@ -55,80 +62,35 @@ test('runs the viewer and tree from in-memory providers without a server', async
await expect(originalPane.locator('.diff-editor-line-delete')).toHaveCount(1);
await expect(modifiedPane.locator('.diff-editor-line-insert')).toHaveCount(1);
- // Render-mode switching keeps the same model pair. Unified mode hides only
- // the original child presentation and interleaves its deleted line as a
- // tokenized ViewZone in the still-interactive modified Viewer.
+ // Algorithm controls are a host/provider concern. DiffEditor itself contains
+ // neither the removed toolbar nor the host-owned layout action.
+ await expect(diff.locator('.moonbit-diff-editor-toolbar')).toHaveCount(0);
+ await expect(diff.locator('[data-action="toggle-diff-layout"]')).toHaveCount(0);
+
await expect(layoutToggle).toBeEnabled();
await expect(layoutToggle).toHaveAttribute('aria-pressed', 'false');
await layoutToggle.click();
await expect(layoutToggle).toHaveAttribute('aria-pressed', 'true');
- await expect(diff).toHaveAttribute('data-render-mode', 'unified');
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
await expect(originalPane).toBeHidden();
- await expect(modifiedPane).toBeVisible();
- const deletedBlock = modifiedPane.locator(
- '.diff-editor-unified-deleted-block',
- );
- await expect(deletedBlock).toContainText('println("hello")');
- await expect(
- modifiedPane.locator('.diff-editor-unified-deleted-line-number'),
- ).toContainText('3');
- await expect(modifiedPane.locator('.diff-editor-line-insert')).toHaveCount(1);
+ await expect(originalPane).toHaveAttribute('aria-hidden', 'true');
+ await expect(modifiedPane).toHaveAccessibleName('Inline diff');
await layoutToggle.click();
- await expect(layoutToggle).toHaveAttribute('aria-pressed', 'false');
await expect(diff).toHaveAttribute('data-render-mode', 'side-by-side');
await expect(originalPane).toBeVisible();
-
- // Desktop can place the changed-file tree beside this surface. Both panes
- // must remain within a substantially narrower caller-owned host.
- const viewerStack = page.locator('.embedded-viewer-stack');
- await viewerStack.evaluate((element) => {
- element.style.width = '300px';
- element.style.flex = '0 0 300px';
- });
- const [narrowDiffBox, originalPaneBox, modifiedPaneBox] = await Promise.all([
- diff.boundingBox(),
- originalPane.boundingBox(),
- modifiedPane.boundingBox(),
- ]);
- expect(narrowDiffBox).not.toBeNull();
- expect(originalPaneBox).not.toBeNull();
- expect(modifiedPaneBox).not.toBeNull();
- expect(originalPaneBox.x).toBeGreaterThanOrEqual(narrowDiffBox.x);
- expect(modifiedPaneBox.x + modifiedPaneBox.width).toBeLessThanOrEqual(
- narrowDiffBox.x + narrowDiffBox.width + 1,
- );
- await expect(
- originalPane.locator('.view-line').filter({ hasText: 'println("hello")' }),
- ).toBeVisible();
- await expect(
- modifiedPane.locator('.view-line').filter({ hasText: 'println(greeting())' }),
- ).toBeVisible();
- await viewerStack.evaluate((element) => {
- element.style.width = '';
- element.style.flex = '';
- });
- const viewportFit = await page.evaluate(() => ({
- innerWidth: window.innerWidth,
- innerHeight: window.innerHeight,
- scrollWidth: document.documentElement.scrollWidth,
- scrollHeight: document.documentElement.scrollHeight,
- }));
- expect(viewportFit.scrollWidth).toBeLessThanOrEqual(viewportFit.innerWidth);
- expect(viewportFit.scrollHeight).toBeLessThanOrEqual(viewportFit.innerHeight);
- await expect(page.locator(sourceEditor)).not.toBeVisible();
+ await expect(modifiedPane).toHaveAccessibleName('Modified file');
await diffToggle.click();
- await expect(diffToggle).toHaveAttribute('aria-pressed', 'false');
- await expect(diffToggle).toHaveAccessibleName('Full diff');
- await expect(diff).not.toBeVisible();
+ await expect(diff).toBeHidden();
await expect(page.locator(sourceEditor)).toContainText('fn main');
// Nested folders resolve lazily on expand.
await expect(page.locator(workspaceItem('src/lib/util.mbt'))).toHaveCount(0);
await page.locator(workspaceItem('src/lib')).click();
- await expect(page.locator(workspaceItem('src/lib'))).toHaveAttribute('aria-expanded', 'true');
-
- // Navigating between files goes through the in-memory document source.
+ await expect(page.locator(workspaceItem('src/lib'))).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
await page.locator(workspaceItem('src/lib/util.mbt')).click();
await expect(page.locator(sourceEditor)).toContainText('util_answer');
await expect(page.locator(workspaceItem('src/lib/util.mbt'))).toHaveAttribute(
@@ -136,343 +98,437 @@ test('runs the viewer and tree from in-memory providers without a server', async
'true',
);
- // The same public Viewer instance selects its Markdown presentation from an
- // ordinary URI-backed in-memory model. No workbench or host-side Markdown
- // parsing/presentation branch participates.
+ // Markdown routing is explicit at the host. The code Viewer is detached and
+ // hidden while the independent MarkdownViewer owns this source model.
await page.locator(workspaceItem('README.md')).click();
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
- await expect(page.locator(workspaceItem('README.md'))).toHaveAttribute(
- 'aria-selected',
- 'true',
- );
- const markdown = page.locator(
- '.viewer-host > .moonbit-viewer-markdown-document',
+ await expect(page.locator('.editor-shell')).toHaveAttribute(
+ 'data-source-presentation',
+ 'markdown',
);
+ await expect(page.locator('.code-viewer-host')).toBeHidden();
+ await expect(page.locator('.markdown-viewer-host')).toBeVisible();
+ const markdown = page.locator(markdownDocument);
await expect(markdown).toBeVisible();
await expect(markdown).toHaveAttribute(
'data-source-uri',
'memory://workspace/README.md',
);
await expect(markdown.locator('h1')).toHaveText('Embedded Markdown document');
- await expect(markdown.locator('strong')).toHaveText('Viewer');
- await expect(page.locator('.viewer-host > .monaco-editor')).toHaveCount(0);
+ await expect(markdown.locator('strong')).toHaveText('MarkdownViewer');
+
+ // Presentation and MoonBit Markdown resource-kind routing use a decoded,
+ // lowercase path rather than case-sensitive suffix checks.
+ await page.locator(workspaceItem('Guide.MD')).click();
+ await expect(markdown).toHaveAttribute(
+ 'data-source-uri',
+ 'memory://workspace/Guide.MD',
+ );
+ await expect(markdown.locator('h1')).toHaveText('Uppercase Markdown');
+ await page.locator(workspaceItem('tour.MBT.MD')).click();
+ await expect(markdown).toHaveAttribute(
+ 'data-source-uri',
+ 'memory://workspace/tour.MBT.MD',
+ );
+ await expect(markdown.locator('[data-markdown-semantic="moonbit-check"]')).toHaveCount(1);
expect(websockets).toEqual([]);
});
-test('virtualizes a legal-size large diff through ordinary Viewer panes', async ({ page }) => {
+test('switches at the 900/901 boundary and keeps explicit Inline above it', async ({ page }) => {
+ await page.setViewportSize({ width: 1500, height: 900 });
await page.goto('/embed.html');
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
-
- // Both sides contain thousands of changed lines. The two Viewer panes keep
- // only a viewport-sized set of line DOM instead of eager diff rows.
- await page.locator(workspaceItem('large.mbt')).click();
- await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
await page.locator('[data-action="toggle-diff"]').click();
- const diff = page.locator('.diff-viewer-host > .moonbit-diff-editor');
- await expect(diff).toBeVisible();
- await expect(
- diff.locator('.moonbit-diff-editor-pane > .monaco-editor'),
- ).toHaveCount(2);
- await expect(diff.locator('.view-line').first()).toBeVisible();
- expect(await diff.locator('.view-line').count()).toBeLessThan(200);
-
- // ViewZone insertion/removal changes content-space scroll offsets. Switching
- // layouts must retain the same modified model line at the same viewport
- // pixel instead of keeping a now-displaced numeric scrollTop.
- const modifiedPane = diff.locator('.moonbit-diff-editor-modified');
- const modifiedScrollable = modifiedPane.locator(
- '.monaco-scrollable-element.editor-scrollable',
- );
- await modifiedScrollable.hover();
- await page.mouse.wheel(0, 18_000);
- await expect
- .poll(async () => (await firstFullyVisibleModelLine(modifiedPane))?.text ?? '')
- .toContain('modified_');
- const splitAnchor = await firstFullyVisibleModelLine(modifiedPane);
- expect(splitAnchor).not.toBeNull();
+ const stack = page.locator('.embedded-viewer-stack');
+ const diff = page.locator(diffEditor);
+ const originalPane = diff.locator('.moonbit-diff-editor-original');
+ const sash = diff.getByRole('separator', { name: 'Resize diff panes' });
const layoutToggle = page.locator('[data-action="toggle-diff-layout"]');
- await layoutToggle.click();
- await expect(diff).toHaveAttribute('data-render-mode', 'unified');
- await expect
- .poll(async () => firstFullyVisibleModelLine(modifiedPane))
- .toEqual(splitAnchor);
- await layoutToggle.click();
+ await setViewerStackWidth(stack, diff, 901);
await expect(diff).toHaveAttribute('data-render-mode', 'side-by-side');
- await expect
- .poll(async () => firstFullyVisibleModelLine(modifiedPane))
- .toEqual(splitAnchor);
+ await expect(originalPane).toBeVisible();
+ await expect(sash).toBeVisible();
+ await expect(sash).toHaveAttribute('aria-valuemin', '10');
+ await expect(sash).toHaveAttribute('aria-valuemax', '90');
+ await expect(sash).toHaveAttribute('aria-valuenow', '50');
+
+ await setViewerStackWidth(stack, diff, 900);
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
+ await expect(originalPane).toBeHidden();
+ await expect(sash).toBeHidden();
+
+ // Responsive fallback never mutates the requested SideBySide preference.
+ await expect(layoutToggle).toHaveAttribute('aria-pressed', 'false');
+ await setViewerStackWidth(stack, diff, 901);
+ await expect(diff).toHaveAttribute('data-render-mode', 'side-by-side');
+
+ // Explicit Inline remains Inline even when the host grows above the boundary.
+ await layoutToggle.click();
+ await expect(layoutToggle).toHaveAttribute('aria-pressed', 'true');
+ await setViewerStackWidth(stack, diff, 1000);
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
+ await expect(originalPane).toBeHidden();
});
-test('keeps tab-expanded unified deletions inside the horizontal scroll extent', async ({
- page,
-}) => {
+test('removes a disabled sash from visibility, tab order, and key handling', async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
await page.goto('/embed.html');
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
-
- await page.locator(workspaceItem('tabbed-deletion.mbt')).click();
await page.locator('[data-action="toggle-diff"]').click();
- await page.locator('[data-action="toggle-diff-layout"]').click();
- const modifiedPane = page.locator(
- '.diff-viewer-host .moonbit-diff-editor-modified',
- );
- const deletedLine = modifiedPane.locator(
- '.diff-editor-unified-deleted-line',
- );
- await expect(deletedLine).toContainText('deleted_tail');
- const widths = await modifiedPane.evaluate((pane) => {
- const rail = pane.querySelector('.view-zones');
- const line = pane.querySelector('.diff-editor-unified-deleted-line');
- const viewport = pane.querySelector(
- '.monaco-scrollable-element.editor-scrollable',
- );
- return {
- rail: Number.parseFloat(rail.style.width),
- deleted: line.scrollWidth,
- viewport: viewport.clientWidth,
- };
- });
- expect(widths.deleted).toBeGreaterThan(widths.viewport + 500);
- expect(widths.rail + 1).toBeGreaterThanOrEqual(widths.deleted);
+ const diff = page.locator(diffEditor);
+ const sash = diff.locator('.moonbit-diff-editor-sash');
+ const toggle = page.locator('[data-action="toggle-diff-sash"]');
+ await expect(sash).toBeVisible();
+ await expect(sash).toHaveAttribute('tabindex', '0');
+ await expect(toggle).toHaveAttribute('aria-pressed', 'true');
+
+ await toggle.click();
+ await expect(toggle).toHaveAttribute('aria-pressed', 'false');
+ await expect(sash).toBeHidden();
+ await expect(sash).toHaveAttribute('aria-hidden', 'true');
+ expect(await sash.getAttribute('tabindex')).toBeNull();
+ expect(
+ await sash.evaluate((node) => {
+ const event = new KeyboardEvent('keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true,
+ });
+ node.dispatchEvent(event);
+ return event.defaultPrevented;
+ }),
+ ).toBe(false);
+
+ await toggle.click();
+ await expect(sash).toBeVisible();
+ await expect(sash).not.toHaveAttribute('aria-hidden', 'true');
+ await expect(sash).toHaveAttribute('tabindex', '0');
});
-test('renders character changes inside the line-level diff', async ({ page }) => {
+test('moves original focus into Inline and keeps F7 navigation live', async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
await page.goto('/embed.html');
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
-
- await page.locator(workspaceItem('src/lib')).click();
- await page.locator(workspaceItem('src/lib/util.mbt')).click();
- await expect(page.locator(sourceEditor)).toContainText('42');
+ await page.locator(workspaceItem('navigation-anchors.mbt')).click();
await page.locator('[data-action="toggle-diff"]').click();
- await page.locator('[data-action="toggle-diff-layout"]').click();
- const diff = page.locator('.diff-viewer-host > .moonbit-diff-editor');
+ const stack = page.locator('.embedded-viewer-stack');
+ const diff = page.locator(diffEditor);
const originalPane = diff.locator('.moonbit-diff-editor-original');
const modifiedPane = diff.locator('.moonbit-diff-editor-modified');
- await expect(diff).toHaveAttribute('data-render-mode', 'unified');
- await expect(originalPane).toBeHidden();
- await expect(
- modifiedPane.locator(
- '.diff-editor-unified-deleted-line .diff-editor-char-delete',
- ),
- ).toHaveText('1');
- await expect(modifiedPane.locator('.diff-editor-char-insert')).toHaveText('2');
- await expect(
- modifiedPane.locator('.diff-editor-unified-deleted-line'),
- ).toHaveCount(1);
- await expect(modifiedPane.locator('.diff-editor-line-insert')).toHaveCount(1);
+ const liveRegion = diff.locator('.moonbit-diff-editor-live-region');
+ await originalPane.locator('.monaco-editor').click({ position: { x: 120, y: 80 } });
+ await expect
+ .poll(() =>
+ originalPane.evaluate((node) => node.contains(document.activeElement)),
+ )
+ .toBe(true);
+
+ // Responsive layout changes do not move focus through a toolbar button.
+ await setViewerStackWidth(stack, diff, 900);
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
+ await expect
+ .poll(() =>
+ modifiedPane.evaluate((node) => node.contains(document.activeElement)),
+ )
+ .toBe(true);
+ await page.keyboard.press('F7');
+ await expect(liveRegion).toHaveText(
+ 'Change 1 of 2; original lines 3 through 3; modified deletion anchor at line 3',
+ );
});
-test('switches standard token and tree diff only for mbt comparisons', async ({ page }) => {
+test('reconciles height-only and Inline-to-side layouts after fresh pane renders', async ({ page }) => {
+ await page.setViewportSize({ width: 1500, height: 1000 });
await page.goto('/embed.html');
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
+ await page.locator(workspaceItem('alignment-zones.mbt')).click();
await page.locator('[data-action="toggle-diff"]').click();
- const diff = page.locator('.diff-viewer-host > .moonbit-diff-editor');
- const toolbar = diff.locator('.moonbit-diff-editor-toolbar');
- const standard = toolbar.getByRole('button', { name: 'Standard diff' });
- const token = toolbar.getByRole('button', { name: 'Token diff' });
- const tree = toolbar.getByRole('button', { name: 'Tree diff' });
- const ignoreComments = toolbar.getByRole('button', {
- name: 'Ignore comments',
+ const stack = page.locator('.embedded-viewer-stack');
+ const diff = page.locator(diffEditor);
+ const spacers = diff.locator('.moonbit-diff-editor-alignment-spacer[monaco-view-zone]');
+ await expect.poll(() => spacers.count()).toBeGreaterThan(0);
+ const beforeIds = await spacers.evaluateAll((nodes) =>
+ nodes.map((node) => node.getAttribute('monaco-view-zone')),
+ );
+
+ // Width is unchanged: the new ids prove a fresh geometry transaction still
+ // replaced the managed alignment zones after the height-only outer layout.
+ await stack.evaluate((node) => {
+ node.style.height = '620px';
+ node.style.flex = '0 0 auto';
});
- await expect(toolbar).toBeVisible();
- await expect(standard).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toBeDisabled();
- await expect(diff).toHaveAttribute('data-ignore-comments', 'true');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'standard');
-
- await token.click();
- await expect(token).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toBeEnabled();
- await expect(diff).toHaveAttribute('data-diff-mode', 'token');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'token');
- await expect(diff).not.toHaveAttribute('data-diff-fallback', 'true');
-
- await tree.click();
- await expect(tree).toHaveAttribute('aria-pressed', 'true');
- await expect(diff).toHaveAttribute('data-diff-mode', 'tree');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'tree');
- await expect(diff).not.toHaveAttribute('data-diff-fallback', 'true');
-
- await standard.click();
- await expect(standard).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toBeDisabled();
- await expect(diff).toHaveAttribute('data-diff-renderer', 'standard');
-
- // Manifests and other non-.mbt files continue to use the existing diff and
- // do not expose language-specific choices.
- await page.locator('[data-action="toggle-diff"]').click();
- await page.locator(workspaceItem('moon.mod')).click();
- await page.locator('[data-action="toggle-diff"]').click();
- await expect(diff).toBeVisible();
- await expect(toolbar).toBeHidden();
- await expect(diff).not.toHaveAttribute('data-moondiff-available', 'true');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'standard');
+ await expect
+ .poll(async () =>
+ spacers.evaluateAll((nodes) =>
+ nodes.map((node) => node.getAttribute('monaco-view-zone')),
+ ),
+ )
+ .not.toEqual(beforeIds);
+ expect(await firstChangedLineTopDelta(diff)).toBeLessThanOrEqual(1);
+
+ await page.locator('[data-action="toggle-diff-layout"]').click();
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve)));
+ await page.locator('[data-action="toggle-diff-layout"]').click();
+ await expect(diff).toHaveAttribute('data-render-mode', 'side-by-side');
+ await expect.poll(() => spacers.count()).toBeGreaterThan(0);
+ expect(await firstChangedLineTopDelta(diff)).toBeLessThanOrEqual(1);
});
-test('toggles comment filtering for token and tree review diffs', async ({ page }) => {
+test('navigates deletion and insertion anchors across side-by-side and Inline layouts', async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
await page.goto('/embed.html');
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
- await page.locator(workspaceItem('comments.mbt')).click();
- await expect(page.locator(sourceEditor)).toContainText('new_value');
+ await page.locator(workspaceItem('navigation-anchors.mbt')).click();
+ await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
await page.locator('[data-action="toggle-diff"]').click();
- const diff = page.locator('.diff-viewer-host > .moonbit-diff-editor');
- const toolbar = diff.locator('.moonbit-diff-editor-toolbar');
- const token = toolbar.getByRole('button', { name: 'Token diff' });
- const tree = toolbar.getByRole('button', { name: 'Tree diff' });
- const ignoreComments = toolbar.getByRole('button', {
- name: 'Ignore comments',
- });
- const ignoreLabel = ignoreComments.locator(
- '.moonbit-diff-editor-ignore-comments-label',
- );
+ const diff = page.locator(diffEditor);
const originalPane = diff.locator('.moonbit-diff-editor-original');
+ const modifiedPane = diff.locator('.moonbit-diff-editor-modified');
+ const liveRegion = diff.locator('.moonbit-diff-editor-live-region');
+ const layoutToggle = page.locator('[data-action="toggle-diff-layout"]');
+ const deletionAnnouncement =
+ 'Change 1 of 2; original lines 3 through 3; modified deletion anchor at line 3';
+ const insertionAnnouncement =
+ 'Change 2 of 2; original insertion anchor at line 6; modified lines 5 through 5';
+ await expect(liveRegion).toHaveAttribute('aria-live', 'polite');
+ await expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
+ await expect(originalPane.locator('.diff-editor-line-delete')).toHaveCount(1);
+ await expect(modifiedPane.locator('.diff-editor-line-insert')).toHaveCount(1);
- // The standalone label collapses inside the actual diff container rather
- // than relying on the whole browser viewport width.
- const viewerStack = page.locator('.embedded-viewer-stack');
- await viewerStack.evaluate((element) => {
- element.style.width = '300px';
- element.style.flex = '0 0 300px';
- });
- await expect(ignoreComments).toBeVisible();
- await expect(ignoreLabel).toBeHidden();
- const toolbarWidth = await toolbar.evaluate((element) => ({
- client: element.clientWidth,
- scroll: element.scrollWidth,
- }));
- expect(toolbarWidth.scroll).toBeLessThanOrEqual(toolbarWidth.client);
- await viewerStack.evaluate((element) => {
- element.style.width = '';
- element.style.flex = '';
- });
+ await modifiedPane.locator('.monaco-editor').click({ position: { x: 120, y: 80 } });
- await token.click();
- await expect(ignoreComments).toBeEnabled();
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'true');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'token');
- const ignoredTokenText = (
- await originalPane.locator('.diff-editor-char-delete').allTextContents()
- ).join(' ');
- expect(ignoredTokenText).toContain('old');
- expect(ignoredTokenText).not.toContain('obsolete');
-
- await ignoreComments.click();
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'false');
- await expect(diff).toHaveAttribute('data-ignore-comments', 'false');
- const visibleTokenText = (
- await originalPane.locator('.diff-editor-char-delete').allTextContents()
- ).join(' ');
- expect(visibleTokenText).toContain('obsolete');
-
- await tree.click();
- await expect(tree).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'false');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'tree');
- await ignoreComments.click();
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'true');
- await expect(diff).toHaveAttribute('data-ignore-comments', 'true');
- const ignoredTreeText = (
- await originalPane.locator('.diff-editor-char-delete').allTextContents()
- ).join(' ');
- expect(ignoredTreeText).not.toContain('obsolete');
-
- // A fully filtered comparison is still a specialized result. Its inserted
- // blank line aligns later source without painting either ignored line.
- await page.locator('[data-action="toggle-diff"]').click();
- await page.locator(workspaceItem('comments-only.mbt')).click();
- await page.locator('[data-action="toggle-diff"]').click();
- await expect(tree).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'true');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'tree');
- await expect(
- originalPane.locator('.diff-editor-line-delete, .diff-editor-char-delete'),
- ).toHaveCount(0);
- await expect(
- diff.locator('.moonbit-diff-editor-modified').locator(
- '.diff-editor-line-insert, .diff-editor-char-insert',
- ),
- ).toHaveCount(0);
- await expect(originalPane.locator('.diff-editor-alignment-zone')).toHaveCount(1);
+ // Side-by-side navigates to the pane that owns physical changed lines.
+ await page.keyboard.press('F7');
+ await expect(liveRegion).toHaveText(deletionAnnouncement);
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ document
+ .querySelector('.moonbit-diff-editor-original')
+ ?.contains(document.activeElement),
+ ),
+ )
+ .toBe(true);
+
+ await page.keyboard.press('F7');
+ await expect(liveRegion).toHaveText(insertionAnnouncement);
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ document
+ .querySelector('.moonbit-diff-editor-modified')
+ ?.contains(document.activeElement),
+ ),
+ )
+ .toBe(true);
+
+ // Inline has one interactive surface. Consecutive F7 presses reveal both
+ // kinds of hunk in the modified editor, including the deletion-only anchor.
+ await layoutToggle.click();
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
+ await modifiedPane.locator('.monaco-editor').click({ position: { x: 120, y: 80 } });
+ await page.keyboard.press('F7');
+ await expect(liveRegion).toHaveText(deletionAnnouncement);
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ document
+ .querySelector('.moonbit-diff-editor-modified')
+ ?.contains(document.activeElement),
+ ),
+ )
+ .toBe(true);
+
+ await page.keyboard.press('F7');
+ await expect(liveRegion).toHaveText(insertionAnnouncement);
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ document
+ .querySelector('.moonbit-diff-editor-modified')
+ ?.contains(document.activeElement),
+ ),
+ )
+ .toBe(true);
+
+ await page.keyboard.press('Shift+F7');
+ await expect(liveRegion).toHaveText(deletionAnnouncement);
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ document
+ .querySelector('.moonbit-diff-editor-modified')
+ ?.contains(document.activeElement),
+ ),
+ )
+ .toBe(true);
+});
+test('virtualizes a 2600-line Inline deletion through the viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await page.goto('/embed.html');
+ await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
+ await page.locator(workspaceItem('large.mbt')).click();
+ await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
await page.locator('[data-action="toggle-diff"]').click();
- await page.locator(workspaceItem('comments.mbt')).click();
- await page.locator('[data-action="toggle-diff"]').click();
- await expect(tree).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'true');
+
+ const diff = page.locator(diffEditor);
+ const modifiedPane = diff.locator('.moonbit-diff-editor-modified');
+ await expect(diff).toBeVisible();
+ await expect(diff.locator('.moonbit-diff-editor-pane > .monaco-editor')).toHaveCount(2);
+ await expect(diff.locator('.view-line').first()).toBeVisible();
+ expect(await diff.locator('.view-line').count()).toBeLessThan(200);
await page.locator('[data-action="toggle-diff-layout"]').click();
- await expect(diff).toHaveAttribute('data-render-mode', 'unified');
- await expect(
- diff.locator('.diff-editor-unified-deleted-line'),
- ).not.toContainText('obsolete explanation');
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
+ const block = modifiedPane.locator(
+ '.diff-editor-inline-deleted-block[data-virtualized-render-lines="true"]',
+ );
+ await expect(block).toHaveCount(1);
+ await expect(block).toHaveAttribute('data-original-line-start', '1');
+ await expect(block).toHaveAttribute('data-original-line-end-exclusive', '2601');
- // The component keeps the preference and selected algorithm when its model
- // pair changes within the mounted review surface.
- await page.locator('[data-action="toggle-diff"]').click();
- await page.locator(workspaceItem('src/lib')).click();
- await page.locator(workspaceItem('src/lib/util.mbt')).click();
- await page.locator('[data-action="toggle-diff"]').click();
- await expect(tree).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toHaveAttribute('aria-pressed', 'true');
- await expect(ignoreComments).toBeEnabled();
+ // Installing a leading Inline zone preserves the raw viewport origin. It
+ // must not restore modified line 1 below the new 46,800px deletion.
+ await expect
+ .poll(() => block.getAttribute('data-render-window-start-model-line'))
+ .toBe('1');
+
+ const top = await inlineDeletedWindowEvidence(modifiedPane);
+ expect(top.rowCount).toBeLessThan(200);
+ expect(top.firstLine).toBe(1);
+ expect(top.validText).toBeTruthy();
+ expect(top.validLineNumbers).toBeTruthy();
+ expect(top.hasTokens).toBeTruthy();
+
+ // The editor's Monaco-shaped wheel path multiplies pixel deltas by 1.25.
+ // 18,720px therefore lands at 23,400px, the middle of the 46,800px zone.
+ await wheelInlinePane(modifiedPane, 18_720);
+ await expect
+ .poll(() => block.getAttribute('data-render-window-start-model-line'))
+ .not.toBe('1');
+ const middle = await inlineDeletedWindowEvidence(modifiedPane);
+ expect(middle.rowCount).toBeLessThan(200);
+ expect(middle.firstLine).toBeGreaterThan(1_000);
+ expect(middle.lastLine).toBeLessThan(1_800);
+ expect(middle.validText).toBeTruthy();
+ expect(middle.validLineNumbers).toBeTruthy();
+ expect(middle.hasTokens).toBeTruthy();
+
+ // A geometry-only Inline reconcile replaces the rendering context but
+ // preserves the current model-line anchor once the projection already
+ // exists. Resizing in the middle must not fall back to the raw/topology
+ // installation behavior.
+ await page.setViewportSize({ width: 1360, height: 900 });
+ await expect
+ .poll(async () => (await inlineDeletedWindowEvidence(modifiedPane)).firstLine)
+ .toBe(middle.firstLine);
+ expect(await diff.locator('.view-line').count()).toBeLessThan(200);
+
+ // Another movement keeps the zone intersecting the viewport while
+ // exposing its final original row rather than overshooting into insertions.
+ await wheelInlinePane(modifiedPane, 18_500);
+ await expect
+ .poll(() => block.getAttribute('data-render-window-end-model-line'))
+ .toBe('2600');
+ const tail = await inlineDeletedWindowEvidence(modifiedPane);
+ expect(tail.rowCount).toBeLessThan(200);
+ expect(tail.lastLine).toBe(2600);
+ expect(tail.validText).toBeTruthy();
+ expect(tail.validLineNumbers).toBeTruthy();
+ expect(tail.hasTokens).toBeTruthy();
+ expect(await diff.locator('.view-line').count()).toBeLessThan(200);
});
-test('uses moondiff token fallback when tree diff cannot parse a model', async ({ page }) => {
+test('shares the Inline DOM budget across twenty 130-line deletions', async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
await page.goto('/embed.html');
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
- await page.locator(workspaceItem('broken.mbt')).click();
- await expect(page.locator(sourceEditor)).toContainText('fn okay');
+ await page.locator(workspaceItem('fragmented-large.mbt')).click();
+ await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
await page.locator('[data-action="toggle-diff"]').click();
+ await page.locator('[data-action="toggle-diff-layout"]').click();
- const diff = page.locator('.diff-viewer-host > .moonbit-diff-editor');
- const toolbar = diff.locator('.moonbit-diff-editor-toolbar');
- const standard = toolbar.getByRole('button', { name: 'Standard diff' });
- const token = toolbar.getByRole('button', { name: 'Token diff' });
- const tree = toolbar.getByRole('button', { name: 'Tree diff' });
- await tree.click();
- await expect(tree).toHaveAttribute('aria-pressed', 'true');
- await expect(diff).toHaveAttribute('data-diff-mode', 'tree');
- await expect(diff).toHaveAttribute('data-diff-renderer', 'token');
- await expect(diff).toHaveAttribute('data-diff-fallback', 'true');
- await expect(toolbar.locator('.moonbit-diff-editor-mode-status')).toHaveText(
- 'Tree diff unavailable — showing token diff',
+ const diff = page.locator(diffEditor);
+ const modifiedPane = diff.locator('.moonbit-diff-editor-modified');
+ const blocks = modifiedPane.locator(
+ '.diff-editor-inline-deleted-block[data-virtualized-render-lines="true"]',
);
+ await expect(diff).toHaveAttribute('data-render-mode', 'inline');
+ await expect(blocks).toHaveCount(20);
+
+ // F7 on a deletion-only Inline hunk reveals the ViewZone top itself, not
+ // the retained modified line after its 130 deleted rows. This also gives
+ // the fragmented budget test a deterministic content-space origin even if
+ // SideBySide alignment had retained a non-zero raw scroll offset.
+ await modifiedPane.locator('.monaco-editor').click({ position: { x: 120, y: 80 } });
+ await page.keyboard.press('F7');
+ await expect
+ .poll(async () => (await fragmentedInlineEvidence(modifiedPane)).activeFragments)
+ .toContain(0);
+ const top = await fragmentedInlineEvidence(modifiedPane);
+ expect(top.rowCount).toBeLessThan(100);
+ expect(top.activeBlockCount).toBeLessThanOrEqual(2);
+ expect(top.validText).toBeTruthy();
+ expect(top.validLineNumbers).toBeTruthy();
+ expect(top.hasTokens).toBeTruthy();
+ expect(top.activeOriginalLines).toContain(1);
+ expect(await diff.locator('.view-line').count()).toBeLessThan(200);
- // Token mode does not require a parseable tree, and leaving the failed
- // mode clears the fallback notice.
- await token.click();
- await expect(diff).toHaveAttribute('data-diff-renderer', 'token');
- await expect(diff).not.toHaveAttribute('data-diff-fallback', 'true');
- await expect(toolbar.locator('.moonbit-diff-editor-mode-status')).toBeHidden();
- await standard.click();
- await expect(diff).toHaveAttribute('data-diff-renderer', 'standard');
+ await wheelInlinePane(modifiedPane, 18_720);
+ await expect
+ .poll(async () => (await fragmentedInlineEvidence(modifiedPane)).activeFragments)
+ .toContain(10);
+ const middle = await fragmentedInlineEvidence(modifiedPane);
+ expect(middle.rowCount).toBeLessThan(100);
+ expect(middle.activeBlockCount).toBeLessThanOrEqual(2);
+ expect(middle.validText).toBeTruthy();
+ expect(middle.validLineNumbers).toBeTruthy();
+ expect(await diff.locator('.view-line').count()).toBeLessThan(200);
+
+ await wheelInlinePane(modifiedPane, 18_700);
+ await expect
+ .poll(async () => (await fragmentedInlineEvidence(modifiedPane)).activeFragments)
+ .toContain(19);
+ const tail = await fragmentedInlineEvidence(modifiedPane);
+ expect(tail.rowCount).toBeLessThan(100);
+ expect(tail.activeBlockCount).toBeLessThanOrEqual(2);
+ // The twentieth 130-line deletion spans original lines 2490..2619;
+ // original line 2620 is its retained anchor and belongs to the code model.
+ expect(tail.activeOriginalLines).toContain(2619);
+ expect(tail.validText).toBeTruthy();
+ expect(tail.validLineNumbers).toBeTruthy();
+ expect(await diff.locator('.view-line').count()).toBeLessThan(200);
});
-test('renders a wide single-line comparison without eager row expansion', async ({ page }) => {
+test('renders character changes in the side-by-side code kernels', async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
await page.goto('/embed.html');
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
-
- await page.locator(workspaceItem('oversized-line.mbt')).click();
- await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
+ await page.locator(workspaceItem('src/lib')).click();
+ await page.locator(workspaceItem('src/lib/util.mbt')).click();
+ await expect(page.locator(sourceEditor)).toContainText('42');
await page.locator('[data-action="toggle-diff"]').click();
- const diff = page.locator('.diff-viewer-host > .moonbit-diff-editor');
- await expect(diff).toBeVisible();
- await expect(
- diff.locator('.moonbit-diff-editor-pane > .monaco-editor'),
- ).toHaveCount(2);
- await expect(diff.locator('.view-line')).toHaveCount(2);
- await expect(diff.locator('.diff-editor-char-delete')).toHaveCount(0);
- await expect(diff.locator('.diff-editor-char-insert')).toHaveCount(0);
+
+ const diff = page.locator(diffEditor);
+ const originalPane = diff.locator('.moonbit-diff-editor-original');
+ const modifiedPane = diff.locator('.moonbit-diff-editor-modified');
+ await expect(diff).toHaveAttribute('data-render-mode', 'side-by-side');
+ await expect(originalPane.locator('.diff-editor-char-delete')).toHaveText('1');
+ await expect(modifiedPane.locator('.diff-editor-char-insert')).toHaveText('2');
+ await expect(originalPane.locator('.diff-editor-line-delete')).toHaveCount(1);
+ await expect(modifiedPane.locator('.diff-editor-line-insert')).toHaveCount(1);
});
-test('drops a stale host-ready rAF after a rapid model swap', async ({ page }) => {
+test('drops a stale host-ready rAF after a rapid code-model swap', async ({ page }) => {
await page.addInitScript(() => {
const queue = [];
globalThis.__embeddedReadyAnimationFrame = (callback) => {
@@ -498,10 +554,6 @@ test('drops a stale host-ready rAF after a rapid model swap', async ({ page }) =
await page.locator(workspaceItem('src/lib')).click();
await expect(page.locator(workspaceItem('src/lib/util.mbt'))).toBeVisible();
-
- // Hold the host-ready callbacks while the Viewer itself continues to render
- // on the browser's native rAF queue. The first callback captures moon.mod;
- // the second captures util.mbt, which is the current model by flush time.
await page.locator(workspaceItem('moon.mod')).click();
await expect
.poll(() => page.evaluate(() => globalThis.__embeddedReadyQueueLength()))
@@ -514,41 +566,137 @@ test('drops a stale host-ready rAF after a rapid model swap', async ({ page }) =
await page.evaluate(() => globalThis.__flushEmbeddedReadyFrame());
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'loading');
-
await page.evaluate(() => globalThis.__flushEmbeddedReadyFrame());
await expect(page.locator('.editor-shell')).toHaveAttribute('data-status', 'ready');
await expect(page.locator(workspaceItem('src/lib/util.mbt'))).toHaveAttribute(
'aria-selected',
'true',
);
- await expect(page.locator('.monaco-editor.readonly-editor')).toContainText(
- 'util_answer',
- );
+ await expect(page.locator(sourceEditor)).toContainText('util_answer');
});
function workspaceItem(path) {
return workspaceSelector(path, { root: 'memory://workspace' });
}
-async function firstFullyVisibleModelLine(pane) {
+async function wheelInlinePane(pane, deltaY) {
+ await pane.locator('.overflow-guard').evaluate((node, wheelDelta) => {
+ node.dispatchEvent(
+ new WheelEvent('wheel', {
+ deltaY: wheelDelta,
+ deltaMode: 0,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+ }, deltaY);
+}
+
+async function inlineDeletedWindowEvidence(pane) {
+ return pane.evaluate((root) => {
+ const block = root.querySelector('.diff-editor-inline-deleted-block');
+ const margin = root.querySelector('.diff-editor-inline-deleted-margin');
+ const rows = Array.from(block?.querySelectorAll('.view-line') ?? []);
+ const margins = new Map(
+ Array.from(margin?.querySelectorAll('.line-numbers') ?? []).map((row) => [
+ Number(row.getAttribute('data-model-line')),
+ row.textContent.trim(),
+ ]),
+ );
+ const lineNumbers = rows.map((row) =>
+ Number(row.getAttribute('data-model-line')),
+ );
+ return {
+ rowCount: rows.length,
+ firstLine: lineNumbers.at(0) ?? 0,
+ lastLine: lineNumbers.at(-1) ?? 0,
+ validText: rows.every((row, index) =>
+ row.textContent.includes(`original_${lineNumbers[index] - 1}`),
+ ),
+ validLineNumbers: lineNumbers.every(
+ (lineNumber) => margins.get(lineNumber) === String(lineNumber),
+ ),
+ hasTokens: rows.some((row) => row.querySelector('[class*="mtk"]')),
+ };
+ });
+}
+
+async function fragmentedInlineEvidence(pane) {
return pane.evaluate((root) => {
- const viewport = root.querySelector(
- '.monaco-scrollable-element.editor-scrollable',
+ const blocks = Array.from(
+ root.querySelectorAll('.diff-editor-inline-deleted-block'),
);
- if (!viewport) return null;
- const viewportRect = viewport.getBoundingClientRect();
- const line = Array.from(root.querySelectorAll('.view-line')).find((row) => {
- const rect = row.getBoundingClientRect();
- return (
- rect.top >= viewportRect.top - 0.5 &&
- rect.bottom <= viewportRect.bottom + 0.5
+ let rowCount = 0;
+ const activeFragments = new Set();
+ const activeOriginalLines = [];
+ let validText = true;
+ let validLineNumbers = true;
+ let hasTokens = false;
+ let activeBlockCount = 0;
+ for (const block of blocks) {
+ const rows = Array.from(block.querySelectorAll('.view-line'));
+ if (!rows.length) continue;
+ activeBlockCount += 1;
+ rowCount += rows.length;
+ const zoneId = block.getAttribute('monaco-view-zone');
+ const margin = root.querySelector(
+ `.diff-editor-inline-deleted-margin[monaco-view-zone="${zoneId}"]`,
);
- });
- if (!line) return null;
- const rect = line.getBoundingClientRect();
+ const margins = new Map(
+ Array.from(margin?.querySelectorAll('.line-numbers') ?? []).map((row) => [
+ Number(row.getAttribute('data-model-line')),
+ row.textContent.trim(),
+ ]),
+ );
+ for (const row of rows) {
+ const originalLine = Number(row.getAttribute('data-model-line'));
+ const match = row.textContent.match(/fragment_(\d+)_deleted_(\d+)/);
+ if (!match) {
+ validText = false;
+ continue;
+ }
+ const fragment = Number(match[1]);
+ const deletedLine = Number(match[2]);
+ activeFragments.add(fragment);
+ activeOriginalLines.push(originalLine);
+ validText &&= originalLine === fragment * 131 + deletedLine + 1;
+ validLineNumbers &&= margins.get(originalLine) === String(originalLine);
+ hasTokens ||= Boolean(row.querySelector('[class*="mtk"]'));
+ }
+ }
return {
- text: line.textContent,
- offset: Math.round((rect.top - viewportRect.top) * 10) / 10,
+ rowCount,
+ activeBlockCount,
+ activeFragments: Array.from(activeFragments),
+ activeOriginalLines,
+ validText,
+ validLineNumbers,
+ hasTokens,
};
});
}
+
+async function setViewerStackWidth(stack, diff, width) {
+ await stack.evaluate((element, nextWidth) => {
+ element.style.width = `${nextWidth}px`;
+ element.style.flex = `0 0 ${nextWidth}px`;
+ }, width);
+ await expect
+ .poll(async () => Math.round((await diff.boundingBox())?.width ?? 0))
+ .toBe(width);
+}
+
+async function firstChangedLineTopDelta(diff) {
+ return diff.evaluate((root) => {
+ const original = root.querySelector(
+ '.moonbit-diff-editor-original .diff-editor-line-delete',
+ );
+ const modified = root.querySelector(
+ '.moonbit-diff-editor-modified .diff-editor-line-insert',
+ );
+ if (!original || !modified) return Number.POSITIVE_INFINITY;
+ return Math.abs(
+ original.getBoundingClientRect().top - modified.getBoundingClientRect().top,
+ );
+ });
+}
diff --git a/editor/tests/browser/smoke/viewer.spec.js b/editor/tests/browser/smoke/viewer.spec.js
index 3dc52e65d..20208b838 100644
--- a/editor/tests/browser/smoke/viewer.spec.js
+++ b/editor/tests/browser/smoke/viewer.spec.js
@@ -349,8 +349,9 @@ test('opens Markdown documents through the native protocol and hovers literate M
await page.goto('/');
await openMainFixture(page);
- // The workbench supplies the untouched URI-backed model. Public Viewer
- // routing alone replaces the code surface with the Markdown presentation.
+ // The workbench supplies the untouched URI-backed model and explicitly
+ // selects its MarkdownViewer sibling. The code-only Viewer remains mounted
+ // so switching back preserves its DOM lifetime, but it must be hidden.
await openWorkspaceFile(page, 'README.md');
await expect(page.locator('.editor-shell')).toHaveAttribute(
'data-source-uri',
@@ -376,7 +377,9 @@ test('opens Markdown documents through the native protocol and hovers literate M
);
await expect(markdown.locator('h1')).toHaveText('Fixture workspace');
await expect(markdown).toContainText('reusable public Viewer');
- await expect(page.locator('.viewer-host > .monaco-editor')).toHaveCount(0);
+ const parkedCodeViewer = page.locator('.viewer-host > .monaco-editor');
+ await expect(parkedCodeViewer).toHaveCount(1);
+ await expect(parkedCodeViewer).toBeHidden();
// `.mbt.md` keeps the original MoonBit model identity. A real pointer over
// the compiler-recognized fence symbol reaches the native `moon ide hover`
diff --git a/editor/viewer/README.mbt.md b/editor/viewer/README.mbt.md
index e68a7a9b8..3dff07af0 100644
--- a/editor/viewer/README.mbt.md
+++ b/editor/viewer/README.mbt.md
@@ -1,668 +1,184 @@
# viewer
-The reusable readonly editor facade. It plays Monaco's
-`CodeEditorWidget`/`ICodeEditor` role over the packages below it; the reference
-workbench, transport, files, and reload policy live under `internal/shell/`.
-`pkg.generated.mbti` is the authoritative public API. This README records the
-ownership and lifecycle rules that are not obvious from signatures.
+The js-only public façade for three readonly surfaces:
-> The code blocks on this page are `mbt nocheck`. This package is js-only and a
-> `Viewer` needs a live DOM host element, which `moon test` (Node, no DOM)
-> cannot provide. The executable proofs for this surface are the Playwright
-> component scenarios under `tests/browser/component/` and the headless
-> white-box suites under `viewer/`; see `docs/harness.md` for the two-layer
-> boundary.
+- `Viewer` is a standalone code editor;
+- `MarkdownViewer` is a rich Markdown document viewer; and
+- `DiffEditor` is a first-class two-model diff editor.
-## The shortest embedding
+`pkg.generated.mbti` is the authoritative API. This page records ownership and
+dependency rules that are intentionally not encoded in public signatures.
-```mbt nocheck
-// The one public construction path. `host` stays caller-owned and must not
-// receive host-rendered children.
-let viewer = @viewer.Viewer::create(host)
-
-// Models are caller-owned too. Installing one is a slot assignment, not a
-// transfer of ownership.
-let model = @model.TextModel(uri, "main.mbt", "moonbit", 1, "rev-1", text)
-viewer.set_model(Some(model))
+## Explicit presentation selection
-// The host tells the Viewer when initial option/view-state setup is done, so
-// visible-token demand is published outside attach_model.
-viewer.handle_initialized()
+Presentation policy belongs to the host. `Viewer` never inspects a URI suffix
+or language id and never creates a Markdown renderer.
-// Teardown removes Viewer-owned DOM but returns the host to the caller intact.
-viewer.dispose()
+```mbt nocheck
+let code = @viewer.Viewer::create(code_host, services=services)
+code.set_model(Some(code_model))
+
+let markdown = @viewer.MarkdownViewer::create(markdown_host, services=services)
+markdown.set_model(Some(@viewer.MarkdownViewerModel(
+ markdown_model,
+ @viewer.OrdinaryMarkdown,
+)))
+
+let moonbit_markdown = @viewer.MarkdownViewerModel(
+ mbt_markdown_model,
+ @viewer.MoonBitMarkdown,
+)
```
-Sharing services between two Viewers is the difference between passing
-`services` and omitting it:
+Hosts normally select `MarkdownViewer` for a decoded lowercase `.md` path or
+the exact `markdown` language id. `.mbt.md` uses `MoonBitMarkdown`; other
+Markdown resources use `OrdinaryMarkdown`. A diff always displays source code,
+including for Markdown paths.
+
+This split is deliberately breaking. `Viewer` is code-only; rich Markdown
+selection is an explicit host responsibility, with no hybrid compatibility
+path inside the code editor.
+
+## Runtime ownership
+
+```text
+public Viewer
+└── internal CodeEditorWidget
+ ├── EditorConfigurationState
+ ├── one model slot
+ │ ├── TextModel borrowed
+ │ ├── ViewModel
+ │ ├── View
+ │ └── MouseHandler
+ └── code contributions
+
+public MarkdownViewer
+└── internal MarkdownViewerWidget
+ ├── TextModel borrowed
+ ├── MarkdownDocumentView
+ └── Markdown hover/definition/folding lifetimes
+
+public DiffEditor
+└── internal DiffEditorWidget
+ ├── DiffEditorViewModel exactly one
+ └── DiffEditorEditors
+ ├── CodeEditorWidget original
+ └── CodeEditorWidget modified
+```
-```mbt nocheck
-// Omitted: the Viewer creates and owns a private bundle.
-let standalone = @viewer.Viewer::create(host)
-
-// Passed: the bundle is borrowed, so both Viewers see the same languages,
-// diagnostics, feedback, and quick-diff state. The caller keeps its lifetime.
-let shared = @viewer.ViewerServices(languages=..., markers=...)
-let left = @viewer.Viewer::create(left_host, services=shared)
-let right = @viewer.Viewer::create(right_host, services=shared)
+`DiffEditorWidget` does not create, contain, or call the public `Viewer`. It
+depends directly on `internal/viewer/code_editor_widget` and coordinates the
+two kernels with one diff generation, one layout transaction, and one feature
+lifecycle. The code widget does not import the public `viewer` package,
+`diff_editor`, or Markdown document packages.
+
+The public `Viewer` is a real opaque wrapper rather than a type alias. This is
+required by the internal package boundary and prevents the kernel type from
+leaking into external interfaces. One wrapper owns exactly one kernel.
+
+## Models and services
+
+Hosts own every `TextModel`, DOM host, and explicitly supplied
+`ViewerServices` value.
+
+- `Viewer::set_model` borrows one model; replacing or clearing it never
+ disposes the model.
+- `MarkdownViewer::set_model` returns the fully detached previous
+ `MarkdownViewerModel?`, so a host may dispose it immediately when it owns the
+ model.
+- `DiffEditor::set_model` installs the original/modified pair atomically and
+ returns the fully detached previous pair. No observer can see a new/old pane
+ mixture.
+- Omitting services creates a surface-owned default bundle. Passing services
+ borrows the caller's bundle, allowing code, Markdown, and diff surfaces to
+ share languages, diagnostics, navigation, feedback, and quick-diff state.
+- `dispose` is idempotent and removes only surface-owned listeners, scheduled
+ work, observers, feature state, and DOM. It never removes the caller's host
+ or disposes caller-owned models or services.
+
+## Diff editor contract
+
+`DiffEditor` accepts a synchronous `DocumentDiffProvider`. The default provider
+uses the Core Myers implementation in `viewer/common/diff`. A diff result has
+two distinct inputs:
+
+- `changes` drive decorations, overview markers, and change navigation; and
+- `additional_alignments` affect geometry only.
+
+The shared `DiffEditorViewModel` publishes `NoModel`, `Pending`, `Ready`, or
+`Failed`. Model, provider, and option changes rotate a generation. Computation
+reads immutable snapshots and commits only when model identity, version,
+provider/options generation, and disposal state still match. A raised provider
+or invalid mapping clears stale artifacts and enters `Failed`.
+
+Each reconcile commits in one direction:
+
+```text
+DocumentDiff
+ -> Decorations
+ -> managed ViewZones
+ -> stable scroll restoration
+ -> Overview
```
-Presentation is selected from the model, never by the host:
+The kernel exposes stable geometry, managed-zone, viewport-state, render, and
+scroll adapters for this work. Diff features do not access raw `View`,
+`MouseHandler`, mutable configuration, or the kernel's private model slot.
-```mbt nocheck
-// A .md or .mbt.md URI path suffix, or the exact "markdown" language id,
-// selects the Markdown root. Everything else selects Code. Shells pass an
-// ordinary TextModel either way and do not branch.
-viewer.set_model(Some(code_model)) // → internal/viewer/browser/view.View
-viewer.set_model(Some(markdown_model)) // → MarkdownDocumentView + hover bridge
-```
+The public terminology is `SideBySide` and `Inline`. The implementation keeps
+no legacy single-column renderer, tail-height compensation, diff-owned
+correction scheduler, or compatibility alias.
-## Construction and ownership
+## Public surfaces
-```d2
-direction: down
+`Viewer` retains the readonly code API: model/options lifecycle, cursor and
+selection queries, scrolling and reveal operations, geometry, model
+decorations, managed ViewZones and overlay widgets, Definition/References,
+folding, and model/cursor/scroll/mouse/disposal events. Markdown section APIs
+are not present on `Viewer`.
-host: host element — caller-owned
-model: TextModel — caller-owned
+`MarkdownViewer` owns Markdown-specific model/options/view-state, section
+folding, source reveal, content-size/scroll/model events, and rich document
+features. Its `MarkdownResourceKind` is explicit and is never inferred inside
+the widget.
-viewer: Viewer {
- config: EditorConfigurationState
- slot: ViewerModelSlot {
- data: "ModelData?" {
- browser: "ModelBrowserData? — View + mouse handler"
- }
- }
- mount: ViewerMount
- contribs: EditorContributions
- delivery: CursorEventDelivery
-}
+`DiffEditor` owns the model pair, provider/options, layout, focus, current diff
+state, update event, and next/previous change navigation. It is readonly; moves,
+hide-unchanged, revert, editing, and the full accessible diff viewer are outside
+the current scope.
-viewer.mount -> host: mounts into, never owns
-viewer.slot.data -> model: borrows readonly
-```
+## Package and architecture boundaries
-- Browser embedders call `Viewer::create(host, services~, options~)`. The host
- element must stay mounted and must not receive host-rendered children.
- This is the only public `Viewer` construction path. The package keeps a
- private headless constructor for white-box tests; there is no public two-step
- mounting API. Construction synchronously reads the host's `clientWidth`/
- `clientHeight` (clamped to Monaco's 5px minimum), so options, model attachment,
- and model-line projection use the measured viewport. The internal ViewLayout
- is seeded synchronously before it becomes observable or publishes stable
- visible-token demand. Later `layout()` calls use the same client-box
- semantics, including while the no-model placeholder is showing. Headless
- means no host, placeholder, browser presentation, DOM focus state,
- measurement, or root animation frame, although a model and `ViewModel` may
- still be installed with `ModelData.browser=None`; a model installed through
- the public mounted path always has `Some(BrowserPresentation)`.
-- Browser embedders that need an editor-capable comparison call
- `DiffViewer::create(host, services?, options?, render_mode?)`, then install a
- caller-owned `DiffEditorModel { original, modified }`. The opaque coordinator
- owns two ordinary `Viewer` instances and exposes `get_render_mode` /
- `set_render_mode` over `DiffEditorRenderMode::{SideBySide, Unified}`. It
- shares one `ViewerServices` bundle, computes line and character mappings
- through `viewer/common/diff`, and applies whole-line plus inner-change
- decorations. Side-by-side rendering inserts alignment `ViewZone`s on the
- shorter side and synchronizes vertical and horizontal scroll. Unified
- rendering keeps the modified Viewer interactive at full width and inserts
- token-classed original deletion blocks, original line numbers, and deletion
- character ranges as modified-side ViewZones. Switching presentation never
- swaps either model. Because both children remain normal Viewers,
- tokenization, selection, feedback, hover, Definition, and Peek keep their
- existing model/provider paths. Revision-aware hosts must make provider
- selectors equally revision-aware; OpenSeek gives the Git HEAD model an inert
- URI scheme and restricts live host providers to `openseek`.
- `set_model(None)`/`clear` detach but never dispose caller models; idempotent
- `dispose` removes both child Viewers and the coordinator subtree without
- removing the host or disposing a caller-supplied service bundle.
-- The `DiffViewer` alignment invariant is one fixed-height logical line in both
- presentations, so it forces soft wrap and folding off in both children.
- Diff panes force the ordinary code presentation even for `.md` resources,
- matching Monaco's two CodeEditorWidgets rather than the standalone Viewer's
- Markdown-document policy. Moved blocks, Monaco's experimental true-inline
- single-line output, revert actions, unchanged-region collapsing, and
- overview-ruler output are deferred. The behavior port is anchored to VS Code
- revision `b18492a288de038fbc7643aae6de8247029d11bd`:
- `diffEditorWidget.ts:491-521` supplies the dual-model/two-CodeEditorWidget
- composition, while
- `components/diffEditorViewZones/diffEditorViewZones.ts:137-420` supplies both
- alignment zones and the `renderSideBySide=false` deleted-line projection.
- Local representation is deliberately smaller: synchronous
- `DefaultLinesDiffComputer` output feeds existing `Viewer` APIs rather than
- porting the observable view-model graph. `diff_viewer_wbtest.mbt` covers
- mapping-to-spacer, unified-anchor, and UTF-16 interval policy;
- `tests/browser/smoke/embed.spec.js` covers real split/unified switching,
- tokenized deletion ViewZones, inner changes, narrow-host fit, and the
- ordinary-Viewer virtualization path.
-- `UnifiedDiffView::create(host, on_comment?)` remains the compatibility
- fallback for embedders that want an eager unified row renderer over strings.
- It is independent of `Viewer`, bounds input at 1,048,576 combined UTF-16 code
- units and 5,000 combined logical lines, and routes through
- `viewer/common/unified_diff`. OpenSeek's product review surface and the
- standalone embedded-viewer example now use `DiffViewer`; the legacy facade
- remains covered as an explicit compatibility fallback.
- The assembled `viewer.css` therefore includes both
- `viewer/browser/diff_editor/diff_editor.css` and
- `viewer/browser/unified_diff/unified_diff.css`.
-- Mounting is a one-way private transition. A mounted Viewer with no model owns
- one atomic placeholder root/text pair; attaching a model installs the active
- presentation root through `ViewerMount`, and ordinary detach restores the
- same pair. Disposal removes Viewer-owned DOM and clears
- placeholder/frame/focus state without returning to headless or releasing the
- caller's host. `get_container_dom_node` returns that original host before and
- after disposal, while `get_dom_node` is nullable when no mounted presentation
- exists.
-- Omitting `services` makes the Viewer create and own an internal bundle.
- Passing `services` explicitly always borrows that bundle, including a bundle
- returned by `ViewerServices(...)`; this is the form for sharing languages,
- diagnostics, feedback, navigation capabilities, and quick-diff state between
- Viewers.
-- `set_model(TextModel?)` installs a caller-owned readonly model in the one
- `ViewerModelSlot.current` bundle. The same object is a no-op; replacement or
- clearing invalidates the generation before cleanup. Code and headless retain
- their existing listener → attached-view → optional View → ViewModel order,
- followed by mounted root removal. Markdown first cancels its bridge and
- disposes semantic/render lifetimes while the root is mounted, removes that
- inert root, then releases listeners, the attached-view handle, and the
- ViewModel. Attachment acquires the handle before `ViewModel` construction and
- selects one private closed presentation kind. Shells and embedders always
- use this same ordinary-model path; they neither parse Markdown nor select a
- presentation. An exact lowercase URI-path `.md` suffix (including `.mbt.md`)
- or the exact `markdown` language id selects
- `Markdown(MarkdownBrowserData)`; every other model selects
- `Code(CodeBrowserData)`. URI matching does not include query/fragment text
- and is case-sensitive. Headless attachments retain the same kind without
- constructing DOM. The Code payload cannot hold a `View` without its retained
- mouse handler and render/reveal facts; the `View` remains the handler's sole
- disposal owner. The Markdown payload owns a focusable root, native viewport,
- replaceable article, persistent overlay mount, same-parse source projection,
- monotonic projection generation, and presentation-local hover and definition
- bridges. Model swaps reset scroll and feature model state; use
- `save_view_state`/`restore_view_state` when the host wants persistence.
-- Contributions are created once per `Viewer` and disposed with it.
- `Viewer.contributions` is the `EditorContributions` owner, and its `instances`
- map is the only per-Viewer instance store. Each central entry owns one
- concrete hover, folding, feedback-input, feedback-widget, quick-diff, or
- Markdown-comment state value, the context-menu shell/source-anchor state, or
- the Definition request/link state, or the shared Definition/References
- Peek/query state, plus its root listeners; feature packages keep no
- editor-id-keyed instance table.
- The content-hover payload additionally
- owns its controller, lazy widget and logical widget view, and timeout/async
- launch policy across model swaps. The Markdown-comment payload owns its
- model/content subscriptions and the current model's stable-key rendered-zone
- map. Construction rejects duplicate ids before side effects, inserts the bare
- typed entry before synchronous initialization, and then installs listeners.
- All modes currently instantiate eagerly.
-- `dispose` is idempotent. The complete contribution map remains installed for
- ordered behavior teardown, then becomes non-lookuppable; retained hover
- browser resources are released at the later root cleanup slot before the map
- is cleared. `on_did_dispose` fires after model detach and owner-decoration
- cleanup but before those contribution and Viewer-lifetime phases, while every
- central entry is still reachable. Disposal cancels pending render work and
- pending per-model smooth-scroll registrations, removes Viewer/View listeners
- and owned DOM, and never disposes the caller's model, host, or explicitly
- supplied services. An internally created service bundle is released
- afterward in marker-decoration, marker, then feedback order.
-- Each attached model stores one marker-decoration acquisition lease and one
- exact attached-view handle in its `ModelData`. Multiple Viewers sharing a
- service and model share the identity owner until the final lease and maintain
- an aggregate attached count; `set_value` refreshes that owner without
- acquiring again. Code publishes the final model-detachment boundary before
- disposing its View, preserving the established Monaco order. Markdown
- publishes that boundary only after its renderer is disposed and presentation
- root removed, so no attachment callback can observe live Markdown feature DOM.
-- Overlay-widget registrations belong to the Viewer and are re-added to each
- Code presentation. With another presentation active, add/remove still update
- the Viewer-lifetime registration map but perform no DOM work. Content widgets
- are an internal code-view-part seam; hover and the definition action's
- request-anchored message own the current implementations.
-
-The active presentation owns DOM lookup, focus, layout, theme, scroll
-position/extents, reveal, visible ranges, render publication, and root
-lifecycle. Markdown content is always rendered from the original `TextModel`
-snapshot and URI; `set_value` replaces only the article contents while
-retaining the root, viewport, and overlay mount. Theme changes perform another
-same-source projection replacement so tokenized fences and Mermaid use the
-current theme. Only `.mbt.md` selects the `MoonBitMarkdown` resource policy;
-there, a full fence info string whose first two nonempty ASCII-space-separated
-tokens are exact lowercase `mbt check` or `moonbit check` is semantic.
-Repeated spaces and trailing tokens are accepted; ordinary Markdown,
-nonmatching fences, case changes, and tab-separated forms remain static. The
-Markdown document exposes one projection-owned semantic pointer mapping. A real
-DOM caret in a semantic row is converted through the retained
-`MarkdownCodeLine` boundary map to the original model's 1-based position;
-synthetic indentation never becomes a provider position. An exact column-zero
-`///|` item marker in those semantic rows is visually replaced by a horizontal
-divider while the original model and DOM text remain unchanged. The
-presentation-local hover and definition bridges consume that mapping and query
-providers against the original model/URI/revision, with no virtual model or
-URI. Live original-model marker decorations are projected into those same rows
-without a block-local marker store or synthetic-model decoration, and their
-marker hover parts are merged with language hover rows.
-
-Every pending Markdown hover commit is stamped with request generation, model
-identity and versions, URI/revision, attach generation, projection generation
-and source version, block identity, source offset, and cancellation token.
-Content, theme, and model replacement retire the relevant state before
-presentation DOM replacement. Layout may update geometry synchronously first;
-layout, marker, pointer, and disposal transitions still invalidate freshness
-before any pending async hover may commit. Projected diagnostics reuse the
-resolved Code class, range, z-index, severity squiggle, and `showUnused`
-underline policy. The `squiggly-inline-unnecessary` opacity and
-`squiggly-inline-deprecated` strike-through effects are explicitly deferred
-because they require mutating source glyphs; the readonly overlay does not
-approximate them.
-
-Markdown reveal converts the validated model range to an absolute source offset
-through `TextSnapshot` and selects the containing or nearest preceding source
-anchor. Visible ranges convert intersecting rendered anchors back through the
-same snapshot; a document with no renderable anchor falls back to its full
-model range. Cursor, selection, ViewZones, editor mouse events, Code folding,
-quick diff, feedback widgets, Markdown comments, and Code-view overlays remain
-dormant on the Markdown variant. Its presentation-owned bridges use native root
-listeners for hover and definition navigation; diagnostic and definition-link
-spans plus the DOM-anchored hover and Peek widgets stay in the Markdown
-projection rather than entering Code contributions.
-
-Markdown section folding is a separate root-Viewer path. The markdown package
-derives sections, reconciliation keys, and default collapses; the browser view
-owns only fold controls and retained-node visibility. A new model seeds the
-default policy, a theme replacement preserves state, and a source replacement
-retains a reader decision only for an unambiguous section whose body is
-unchanged. Changed, new, or ambiguous sections expand, and a model swap resets
-the state. Standalone toggles publish final visibility before invalidating the
-hover bridge; replacement reconciles visibility inside the existing projection
-replacement bracket.
-
-## Public surface
-
-The API is a readonly subset of Monaco's editor API:
-
-- lifecycle/model/options: `create`, `set_model`, `handle_initialized`,
- `update_options`, `layout`, `focus`, `dispose`;
-- position, selection, scroll, reveal, geometry, and read queries;
-- model decorations and `EditorDecorationsCollection`;
-- view zones and null-position overlay widgets;
-- Markdown section folding through `toggle_markdown_section_at` and
- `collapsed_markdown_sections`;
-- precomputed reference presentation through
- `show_references(Position, Array[Location])`;
-- model, cursor, scroll, ViewZone, hidden-area, mouse, and disposal events.
-
-Canonical public event values and editor-option enums are owned by
-`viewer/common/editor_api`; the root facade consumes those types directly and
-does not provide compatibility aliases.
-
-`update_options` accepts and replaces one complete typed `ViewerOptions`
-snapshot. It is intentionally not Monaco's JavaScript partial-object merge:
-the representation is opaque, and callers changing one field derive a new
-snapshot with a reviewed builder such as
-`viewer.get_options().with_render_whitespace(All)`. The public read floor is
-limited to `soft_wrap()` and `line_height()`; internal packages derive the rest.
-An equal complete snapshot is a strict no-op. A changed snapshot computes
-option-specific facts once and delivers the resulting mapping, decoration, and
-configuration view events as one ordered batch before scheduling the frame.
-The readonly product keeps its approved
-`render_validation_decorations=On` default; `Editable` still filters validation
-decorations because readonly is a fixed product fact.
-
-The viewer is single-cursor: secondary cursor/selection arrays are empty and
-`set_selections` uses the first selection. The primary Left/Right/Up/Down,
-PageUp/PageDown, Home, and End bindings (with Shift variants) move the cursor;
-a winning binding remains handled at a document boundary. Pointer click/drag,
-word, line, gutter, and select-all gestures use source-shaped command objects
-registered under their source IDs and the same transition path. Their
-runtime-partial argument shapes return before mutation when required
-position/selection data is absent; the readonly model has no undo stack, so
-Monaco's cursor-command `pushStackElement` calls have no local state to update.
-Alternate platform bindings, editable commands, multi-cursor, and column
-selection are outside the readonly surface. Build/render/hover telemetry and
-the disabled-by-default scroll state/render-phase trace are not part of the
-public Viewer API; the internal workbench and browser scenarios use the
-Viewer-id-keyed `internal/viewer/browser/testing` seam.
-
-Cursor payload versions are `TextModel.get_version_id()`, never the caller's
-host metadata version. `set_position`/`set_selection` accept an optional
-source, while `set_selections` accepts optional source and reason; their
-defaults are `api`/`NotSet`. Keyboard and ordinary pointer gestures emit
-`keyboard` or `mouse` with `Explicit`;
-four-click SelectAll deliberately keeps source `keyboard`. `set_value` resets
-the cursor to `(1,1)` and emits source `model`/reason `ContentFlush`, old version
-`0`, and `old_selections=None`, even when the visible cursor was already at the
-origin. State is committed and rendering is scheduled before public cursor
-callbacks. Delivery is FIFO and reentrancy-safe: ordinary transitions fire
-position then selection as an adjacent pair, while flush delivery is model
-content, position, then selection. The private `CursorEventDelivery` owns the
-queue, active drain gate, exact content-barrier depth, and completed versions.
-A model reset clears queued model facts and versions but never clears an active
-outer drain gate; active-model checks and public event effects remain at the
-root Viewer boundary.
-
-`on_did_change_hidden_areas` is the public, payload-free notification for the
-current model's changed view-line mapping. The root subscribes to the
-ViewModel's heterogeneous outgoing dispatcher through the model-scoped
-generation gate, so a detached model, a missing model, and callbacks retired by
-replacement or disposal cannot reach the Viewer emitter. The event fires once
-after the hidden-area transaction has delivered its internal mapping,
-cursor/decorations, layout, and stable-viewport recovery facts. Equal ranges
-and a forced update that leaves the mapping equal do not fire. Applying hidden
-areas remains package-private root/contribution behavior rather than a public
-Viewer mutation API.
-
-On a mounted Viewer, the Markdown-comment contribution resolves whole-line
-blocks from the first matching provider or the language's comment
-configuration, then replaces those lines only in the view projection with
-measured ViewZones. The model value, coordinates, tokens, selection, and code
-copy remain source truth. Each stable range key retains one DOM target while a
-body-only change replaces its shared safe-renderer lifetime; same-model content
-flushes force the hidden-source projection refresh even when the normalized
-ranges stay equal. Model detach disposes renderers and size observers, removes
-zones, and clears the feature's one hidden source before the outgoing browser
-`View` is destroyed. A headless Viewer never hides these source lines because
-it has no replacement DOM. Offscreen zones measure invisibly at the editor
-content width, so scroll geometry converges before first reveal and responds to
-width/image changes without exposing hidden content. The feature enables only
-sanitized HTTP(S) images and routes sanitized links through its external action
-handler; raw HTML and unsafe schemes stay inert under the shared renderer
-policy.
-
-Cursor-command reveals keep the committed view coordinate. Keyboard commands
-request non-minimal Smooth reveal; pointer MoveTo/Word/Line commands use the
-same Smooth request with `minimalReveal=true` after their `None` gate, so a
-target already at the viewport edge gains no extra vertical or horizontal
-padding. Smooth requests of at most one line downgrade to immediate, and the
-`smooth_scrolling=false` default also commits them immediately.
-
-## HTML editor context menu
-
-The browser Viewer uses a Monaco-shaped HTML context menu for definition
-commands. A right click on Code content text or empty content focuses the
-Viewer and moves the cursor only when the hit is outside the current selection.
-A right click on an exact semantic `.mbt.md` source row anchors the same
-original-model definition position. The live menu contains
-`Go to Definition` and `Peek Definition` as adjacent top-level actions;
-unavailable actions disappear. Ordinary Markdown, synthetic padding, injected
-text, margins, widgets, scrollbars, stale projections, and a Viewer with no
-available definition command retain the browser-native menu.
-
-The context-menu contribution owns one lazy browser widget per Viewer. Showing
-again replaces its transient DOM; running an action hides first. Escape, Tab,
-outside primary pointer input, focus/window blur, model/content change, scroll,
-and disposal dismiss it. Shift+F10 or the Context Menu key opens at the Code
-cursor, or at Markdown's most recent still-valid semantic pointer anchor.
-Keyboard navigation supports Up/Down/Home/End/PageUp/PageDown and Enter/Space.
-The root overlay prefers right/down placement, flips at viewport edges, exposes
-`menu`/`menuitem`, and restores the prior focused element when focus still
-belongs to the menu. The reusable browser shell can render one submenu level,
-but this definition menu does not instantiate one.
-
-This is a Monaco-shaped VS Code Web behavior port with the flattened definition
-command layout as a local product choice, not a desktop-native menu or a public
-extension surface. Clipboard/edit/refactor/source/history entries, scrollbar
-actions, touch long press, visible disabled actions, icons, mnemonics, and
-deeper submenus remain outside this first surface. Independent Viewers each own
-their menu rather than sharing a process-global context-menu service.
-
-## Definition navigation and References Peek
-
-Definition UI is a behavior port of Monaco standalone. In Code, F12 and the web
-Ctrl/Cmd+F12 fallback request definitions at the current cursor. In semantic
-`.mbt.md` fences, F12 uses the most recent valid projected pointer position.
-The browser runner starts every matching provider concurrently; completed live
-results are flattened in Monaco registry priority (selector score, then newest
-registration) and exact URI/range duplicates are removed. One result opens
-directly. Multiple results open Peek in an outer mounted Viewer without
-querying providers again; a headless or nested Viewer retains the deterministic
-provider-first fallback. A same-resource result is applied locally at its
-collapsed start with reveal and focus; Code also updates its cursor. A direct
-target paints the complete result range with `symbolHighlight`; the decoration
-clears after 350 ms only while the exact target model remains installed. A
-cross-resource result is sent to the optional host-owned
-`LocationOpenerHandle`; the reference workbench applies the same target-range
-feedback after installing the resolved model. Rejection or absence produces
-non-destructive feedback only while the initiating model/version and opener
-generation remain current, so a late failure cannot overwrite a newer
-navigation. A zero-result Code request uses the source word in
-`No definition found for ''` when available and mounts the message as an
-above/below content widget at the validated request position. Neither Viewer
-nor the request value owns files, tabs, workspace, groups, navigation history,
-or transport.
-
-Ctrl/Command+Click uses an algorithm-fidelity gesture state rather than a
-second ordinary-click path. Only exact platform Ctrl or Command over real
-content text starts resolution; injected text, foreign elements, margins,
-widgets, and non-word positions are excluded. The token becomes a link only
-after a non-empty result. An empty result is cached without feedback while the
-pointer stays on that word during the exact modifier gesture, avoiding repeated
-semantic checks on every mousemove. Moving within one word reuses the preview
-request. Selection suppression occurs only when a left mousedown with click
-detail at most one matches that armed token and the modifier is held; a
-same-line eligible mouseup launches a fresh ordinary Definition action even
-when the preview request is still pending. Modifier release, unrelated
-Ctrl/Cmd chords, blur, scroll, drag, leave, model/content change, and disposal
-cancel the transient link gesture. A selection change cancels the preview
-request; when an unresolved link mousedown already recorded its source line, it
-preserves that down-line until the same-line mouseup launches the fresh action.
-Semantic Markdown stores the most recent plain-click pointer target as its
-command anchor; modifier edges do not replace it, while leave, scroll,
-projection/model change, and disposal clear it. Code paints an armed link as a
-model decoration. Semantic Markdown asks the document projection to paint the
-exact source range as caller-owned spans; ordinary fences and synthetic padding
-stay inert, and projection replacement removes those spans before their DOM
-retires.
-
-### Shared Peek controller
-
-Alt+F12 and multiple Definition results populate the same per-Viewer
-References controller used by the public precomputed-locations entry. Provider
-requests and Definition-specific no-result/open-rejection messages stay in
-the Definition contribution. References provider requests stay in the
-References contribution. Both feed already-computed locations into the shared
-result session.
-
-Code requests the Monaco-default 18 lines with a blank ViewZone, reduces toward
-80% of the live viewport while retaining the inherited 12-line floor, and
-aligns the interactive shell through a Viewer-owned overlay. The
-anchor-to-following-line range is revealed after insertion so the spacer
-participates in scroll fitting. Semantic Markdown mounts the shell in its
-persistent projection overlay and stamps the session with the current
-projection generation. Both modes place a nested readonly preview on the left
-and a feature-local ARIA result tree on the right.
-
-Locations are copied, sorted by canonical URI string and full range, exactly
-deduplicated, grouped by URI, and retained as one flat circular navigation
-sequence. Multi-resource results render file rows with basename, parent, and
-count; one-resource results omit the redundant file row. The initially nearest
-resource is expanded. Same-resource row snippets use the attached model.
-Another group resolves lazily on first expansion through
-`TextModelResolverHandle`; its lease is independent of the selected-preview
-lease and remains retained until session close. A failed snippet resolution
-keeps the selectable `basename:line:column` fallback.
-
-The selected preview reuses the attached model for the same URI or resolves its
-own `TextModelReference` for another URI. It decorates every result in the
-previewed resource and adds one distinct selected decoration last. A slow
-replacement keeps the installed child/reference until the new preview is
-current and ready. Missing, disposed, or wrong-URI selected models show
-`No preview available`; stale completions cannot mutate the shell and release
-any returned lease exactly once.
-
-Focus is scheduled after the Code ViewZone becomes visible. F4/Shift+F4 cycle
-through the flat result sequence; preview focus is restored only when the
-retiring child still owned it at commit time. Enter or double-click opens
-Current, Ctrl/Command+Enter opens Side, Enter inside the nested preview remains
-native, and Escape closes and restores outer focus. Current opening reveals an
-exact same-resource target locally; Side and cross-resource Current requests go
-through `LocationOpenerHandle`. Definition alone presents opener-rejection
-feedback. A nested preview borrows services but cannot recursively open Peek.
-It retains raw source presentation: whole-line Markdown-comment replacement is
-an outer-Viewer contribution and cannot shift a selected target inside the
-compact preview.
-
-Every widget callback checks its captured session generation. Every async
-completion checks the source model/attachment/content version, session and
-request generations, mount, cancellation source, and exact returned URI. Close
-or replacement first detaches all selected/group owner slots, so cancellation
-or lease-release callbacks can safely open a newer session. Root teardown then
-releases the nested Viewer, selected reference, group references, decorations,
-shell/overlay, and final Code ViewZone or Markdown mount without touching a
-reentrant replacement.
-
-Definition's same model/version/position command toggles its Peek. A
-zero-result Definition request closes its loading shell and reports the
-word-specific `No definition found` feedback rather than retaining an empty
-dialog. Confirmation is stamped with both the source model and latest open
-intent, so queued work cannot overwrite a newer cursor or navigation action.
-
-### Provider-backed Peek References action
-
-Shift+F12 and the top-level `Peek References` editor context-menu row query the
-current Code cursor or most recent valid semantic-Markdown pointer anchor. The
-action is available only on a mounted outer Viewer with a matching References
-provider. It opens the shared shell immediately in `Loading references...`,
-queries every matching live provider, isolates provider failures, and flattens
-results in selector/registration priority regardless of completion order.
-An authoritative empty result remains visible as `No references found`.
-
-The request is stamped with the source model, attachment generation, content
-version, anchor, session generation, and cancellation identity. Cursor or
-semantic-anchor movement, model/content replacement, another navigation or
-Peek intent, close, and disposal all reject stale completion. The reference
-workbench registers its remote language client as the provider; the native
-host executes
-`moon ide find-references --loc --json`. Moon IDE's result
-includes the declaration, and the shared model applies its existing exact
-sort/dedup/group normalization before rendering.
-
-### Public precomputed References entry
+Dependencies point downward:
-```mbt nocheck
-viewer.show_references(anchor, locations)
+```text
+viewer/common/diff
+ ↑
+internal/code_editor_widget <- internal/diff_editor
+ ↑ ↑
+public viewer façade ---------------┘
```
-`show_references` is available only on a live mounted outer Viewer with a
-current Code or Markdown presentation. It validates the anchor, copies the
-caller array, and displays exactly that set after the shared exact
-deduplication; it never queries a References provider or infers declaration
-semantics. The exact same model/attachment/content version/anchor toggles the
-References session closed; another anchor replaces it. An empty set retains an
-accessible `No references found` dialog.
-
-Opening References retires pending Definition actions, link gestures, and
-feedback so older intent cannot later replace the direct session. This API does
-not register or query a References provider; the provider-backed action above
-is an independent contribution using the same result controller. Go to
-References, CodeLens, document highlights, a Workbench References View,
-filtering, copying, history, virtualization, and a resizable/persisted split
-remain outside the Viewer surface.
-
-`ViewerServices` is an opaque capability aggregate. Its constructor accepts a
-`LanguageHandle`, one closed marker source (`MarkerStore` or `Decorations`), an
-`AgentFeedbackHandle`, optional `LocationOpenerHandle` and
-`TextModelResolverHandle` navigation capabilities, a `QuickDiffHandle`, and a
-`LogHandle`; concrete service fields cannot be recovered through the facade.
-Hosts retain concrete language, marker, feedback, navigation, quick-diff, and
-logging backings when they need to register providers, publish diagnostics,
-mutate feature state, open a resolved location, resolve a Peek model, or inspect
-telemetry. Omitted marker, feedback, quick-diff, language, and logging
-capabilities create bundle-owned defaults. Navigation capabilities have no
-default: same-resource navigation needs no host capability, while unavailable
-cross-resource opening or preview remains explicit. `ViewerServices::dispose`
-is idempotent and releases only bundle-created defaults, in marker-decoration,
-marker-store, then feedback order; supplied handles and captured backings remain
-caller-owned. A Viewer disposes only a bundle it created implicitly. The
-navigation capabilities are consumed by Definition/References opening and by
-cross-resource group or selected-preview resolution; same-resource navigation
-and snippets remain Viewer-local.
-
-## Runtime pipeline
-
-```mermaid
-flowchart LR
- A[TextModel
caller-owned] --> B[Tokenization
model part]
- B --> C[ViewModel + ViewLayout
per model]
- C --> D[Typed ViewEvent
FIFO]
- D --> E[View + ViewParts
browser only]
- E --> F[Shared frame queue:
measure, then DOM write]
+These boundaries remain visible in `moon.pkg`, generated interfaces, and code
+review:
+
+- `diff_editor` depends on `code_editor_widget` and never on public `Viewer`;
+- `code_editor_widget` never depends on public `viewer` or `diff_editor`;
+- the code kernel contains no hybrid Markdown presentation policy;
+- the code kernel does not export raw configuration, `ViewModel`, `View`, or
+ `MouseHandler` accessors;
+- diff code contains no raw editor internals, `MutationObserver`, tail balance,
+ or correction scheduler; and
+- the public `Viewer` is an opaque one-kernel façade.
+
+Fast validation for this boundary:
+
+```sh
+moon -C editor check viewer internal/viewer/code_editor_widget --target js --deny-warn
+moon -C editor test internal/viewer/code_editor_widget --target js --deny-warn
```
-`base/browser` owns the one realm-global frame coordinator. ViewModel smooth
-scroll and controller touch inertia use its strict-next queue; a mounted Viewer
-uses current-or-next priority `100` for its coalesced render. Therefore an
-animation tick can commit state and append that Viewer render to the same
-native frame, while strict-next animation state still advances only once per
-frame. Multiple Viewers share the native request and priority queue but retain
-independent dirty flags, callbacks, trace ids, and cancellation lifetimes. This
-is private composition and leaves `viewer/pkg.generated.mbti` unchanged; it is
-not Monaco's cross-editor phased `EditorRenderingCoordinator` port.
-
-The root package owns every `Viewer::` method and the cross-package glue for
-input, reveal, widgets, folding, hover, definition navigation and Peek,
-Markdown comments, quick diff, feedback, and decorations. Public values remain
-in `viewer/common/**` and
-`viewer/browser`;
-concrete browser and contribution mechanisms live in
-`internal/viewer/browser/**` and `internal/viewer/contrib/**`. Those
-lower-level packages do not import the root facade, so Viewer-facing
-composition stays here without reversing dependencies.
-
-`fold_top_level` remains an explicit host action rather than an attachment
-default. For MoonBit models it lexically identifies brace-bodied top-level
-functions and uses the opening-brace line as each function's fold header. This
-keeps every source-formatted signature line and `{` visible while hiding later
-body lines and the closing brace; its collapsed chevron omits the generic
-ellipsis. The fold is therefore anchored on the final signature line, an
-intentional interaction trade-off that avoids partial-line projection and its
-cursor/wrapping coordinate machinery. `declare` and `=`-backed bodyless
-functions are ignored, and comments and strings do not participate in brace
-matching. Top-level runs of two or more plain `//` comment lines — typically
-the copyright/file header — fold from their first line and keep the ordinary
-collapsed ellipsis; doc comments (`///`, including `///|`) never join a run.
-Other languages and MoonBit declaration kinds retain ordinary level-one
-folding.
-
-Each hover request
-captures the physical `TextModel`, its internal content version, a
-Viewer-lifetime monotonic generation, and a caller-owned cancellation token.
-Replacement, content invalidation, detach, model disposal, and Viewer disposal
-cancel before retiring the request; only a stamp that is still fully current
-may mutate decorations, hover state, rendering, or resolution events. Hover
-mouse/async/sync/loading delays are clearable owned handles with a generation
-guard retained for dispatch races.
-
-Grammar tokenization is demand-driven. Model attach/build telemetry and token
-change telemetry read the passive store only; `_attachModel` does not own
-Monaco's separate `handleInitialized` transition. ViewModel scroll/content
-changes publish unstable visible ranges. After `set_model` and any synchronous
-view-state restore or option update, the host calls `handle_initialized`; that
-separate boundary publishes the current model's stabilized visible demand
-without waiting for a render frame. Repeated calls re-stabilize that demand and
-can immediately supersede a pending unstable-range debounce.
-`restore_view_state(Some(...))` also publishes a stabilized range after the
-restored scroll is committed. Model attachment alone, including headless
-attachment, publishes no synthetic zero-viewport stable range. This behavior
-port does not claim full constructor
-arithmetic parity: real DOM `FontInfo` replaces the initial estimate in the
-first mounted read phase, and wrap-column computation retains the documented
-local reduced formula. The browser scheduler selects one native-idle or 15 ms
-timeout fallback implementation, exposes cancellable idle/zero/delay handles,
-and lets the model backend bound and cancel background work. The root consumes
-the ViewModel's typed outgoing token event only after the browser ViewEvent has
-been delivered; it does not subscribe directly to TextModel token events.
-
-## Boundaries and checks
-
-- JS-only: DOM access is through `rabbita/dom`/`rabbita/js`; no Rabbita
- TEA/vdom/command layer, `internal/shell/**`, backend transport, or concrete
- `syntax/lang_*` import belongs here.
-- DOM-free logic belongs in `viewer/common/**` or a multi-target contribution.
- The browser renders typed state directly; do not pass render-frame JSON to
- JavaScript.
-- Owner-adjacent CSS is assembled by `scripts/build-web.mbtx`.
-- Use `just test` for headless/model behavior, `just test-browser-smoke` for
- routine real-DOM input and geometry, and `just test-browser-perf` for opt-in
- performance diagnosis. The read-only check gate is
- `moon check --target all --warn-list +73 --deny-warn` followed by `moon fmt --check`; see
- `docs/quality.md` for the complete required checks and architecture review
- rules.
+Run `just check` from the repository root for the integration gate.
+
+Browser behavior is covered by `just editor-test-browser`; the real downstream
+acceptance target remains packaged SeekMoon through its Proton/CEF CDP path.
diff --git a/editor/viewer/browser/diff_editor/diff_editor.css b/editor/viewer/browser/diff_editor/diff_editor.css
index 47ea962a7..7784a67de 100644
--- a/editor/viewer/browser/diff_editor/diff_editor.css
+++ b/editor/viewer/browser/diff_editor/diff_editor.css
@@ -2,190 +2,79 @@
position: absolute;
inset: 0;
display: flex;
- flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--vscode-editor-background);
- container-type: inline-size;
}
-.moonbit-diff-editor-toolbar {
- z-index: 1;
+.moonbit-diff-editor-panes {
+ position: relative;
display: flex;
- flex: 0 0 32px;
- align-items: center;
- gap: 8px;
+ flex: 1 1 auto;
min-width: 0;
- padding: 0 10px;
- border-bottom: 1px solid
- var(--vscode-diffEditor-border, var(--vscode-editorWidget-border));
- color: var(--vscode-foreground, #3b3b3b);
- background: var(--vscode-editorWidget-background, #f8f8f8);
- font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, sans-serif);
- font-size: 12px;
-}
-
-.moonbit-diff-editor-toolbar[hidden] {
- display: none;
-}
-
-.moonbit-diff-editor-toolbar-label {
- flex: 0 0 auto;
- color: var(--vscode-descriptionForeground, #6b6b6b);
- font-weight: 600;
-}
-
-.moonbit-diff-editor-mode-group {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
+ min-height: 0;
overflow: hidden;
- border: 1px solid
- var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.35));
- border-radius: 5px;
-}
-
-.moonbit-diff-editor-mode {
- height: 22px;
- padding: 0 9px;
- border: 0;
- border-right: 1px solid
- var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.35));
- color: inherit;
- background: transparent;
- font: inherit;
- line-height: 22px;
- cursor: pointer;
-}
-
-.moonbit-diff-editor-mode:last-child {
- border-right: 0;
-}
-
-.moonbit-diff-editor-mode:hover {
- background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.12));
}
-.moonbit-diff-editor-mode:focus-visible {
+.moonbit-diff-editor-pane {
position: relative;
- z-index: 1;
- outline: 1px solid var(--vscode-focusBorder, #007fd4);
- outline-offset: -1px;
+ flex: 1 1 50%;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
}
-.moonbit-diff-editor-mode.active {
- color: var(--vscode-button-foreground, #ffffff);
- background: var(--vscode-button-background, #0e639c);
+.moonbit-diff-editor-inline .moonbit-diff-editor-modified {
+ flex-basis: 100%;
}
-.moonbit-diff-editor-ignore-comments {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- height: 22px;
- padding: 0 7px;
- border: 1px solid
- var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.35));
- border-radius: 5px;
- color: inherit;
+.moonbit-diff-editor-sash {
+ position: relative;
+ z-index: 20;
+ flex: 0 0 4px;
+ width: 4px;
+ margin-inline: -2px;
+ border: 0;
+ outline: 0;
background: transparent;
- font: inherit;
- line-height: 20px;
- cursor: pointer;
+ cursor: col-resize;
+ touch-action: none;
}
-.moonbit-diff-editor-ignore-comments:hover:not(:disabled) {
- background: var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.12));
-}
-
-.moonbit-diff-editor-ignore-comments:focus-visible {
- outline: 1px solid var(--vscode-focusBorder, #007fd4);
- outline-offset: -1px;
-}
-
-.moonbit-diff-editor-ignore-comments.active {
+.moonbit-diff-editor-sash::before {
+ position: absolute;
+ inset-block: 0;
+ left: 1px;
+ width: 1px;
+ content: "";
background: var(
- --vscode-toolbar-activeBackground,
- var(--vscode-toolbar-hoverBackground, rgba(127, 127, 127, 0.12))
+ --vscode-diffEditor-border,
+ var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.35))
);
}
-.moonbit-diff-editor-ignore-comments:disabled {
- opacity: 0.55;
- cursor: default;
+.moonbit-diff-editor-sash:hover::before,
+.moonbit-diff-editor-sash:focus-visible::before {
+ left: 0;
+ width: 3px;
+ background: var(--vscode-focusBorder, #007fd4);
}
-.moonbit-diff-editor-ignore-comments-icon {
- color: var(--vscode-symbolIcon-colorForeground, #8064c8);
- font-family: var(--editor-font-family, ui-monospace, monospace);
- font-size: 11px;
- font-weight: 700;
-}
-
-.moonbit-diff-editor-ignore-comments-label {
- margin-left: 5px;
- white-space: nowrap;
-}
-
-.moonbit-diff-editor-mode-status {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- color: var(--vscode-descriptionForeground, #6b6b6b);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.moonbit-diff-editor-mode-status[hidden] {
+.moonbit-diff-editor-sash[hidden] {
display: none;
}
-@container (max-width: 420px) {
- .moonbit-diff-editor-toolbar {
- gap: 5px;
- padding-inline: 6px;
- }
-
- .moonbit-diff-editor-ignore-comments {
- width: 26px;
- padding-inline: 0;
- }
-
- .moonbit-diff-editor-ignore-comments-label {
- display: none;
- }
-}
-
-.moonbit-diff-editor-panes {
- position: relative;
- display: flex;
- flex: 1 1 auto;
- min-width: 0;
- min-height: 0;
- overflow: hidden;
-}
-
-.moonbit-diff-editor-pane {
- position: relative;
- flex: 1 1 50%;
- min-width: 0;
- min-height: 0;
+.moonbit-diff-editor-live-region {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
overflow: hidden;
-}
-
-.moonbit-diff-editor-original {
- border-right: 1px solid
- var(--vscode-diffEditor-border, var(--vscode-editorWidget-border));
-}
-
-.moonbit-diff-editor-unified .moonbit-diff-editor-original {
- display: none;
-}
-
-.moonbit-diff-editor-unified .moonbit-diff-editor-modified {
- flex-basis: 100%;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
}
.moonbit-diff-editor .diff-editor-line-delete {
@@ -216,34 +105,31 @@
background: var(--vscode-diffEditor-diagonalFill, transparent);
}
-.moonbit-diff-editor .diff-editor-unified-deleted-block,
-.moonbit-diff-editor .diff-editor-unified-deleted-margin {
+.moonbit-diff-editor .diff-editor-inline-deleted-block,
+.moonbit-diff-editor .diff-editor-inline-deleted-margin {
box-sizing: border-box;
overflow: hidden;
color: var(--vscode-editor-foreground);
background: var(--vscode-diffEditor-removedLineBackground, #ff000033);
- font-family: var(--editor-font-family);
- font-size: var(--editor-font-size);
- font-variant-ligatures: none;
}
-.moonbit-diff-editor .diff-editor-unified-deleted-line,
-.moonbit-diff-editor .diff-editor-unified-deleted-line-number {
+.moonbit-diff-editor .diff-editor-inline-deleted-line,
+.moonbit-diff-editor .diff-editor-inline-deleted-line-number {
box-sizing: border-box;
height: var(--editor-line-height);
line-height: var(--editor-line-height);
}
-.moonbit-diff-editor .diff-editor-unified-deleted-line {
+.moonbit-diff-editor .diff-editor-inline-deleted-line {
min-width: max-content;
white-space: pre;
}
-.moonbit-diff-editor .diff-editor-unified-deleted-line[data-empty-line="true"]::after {
- content: "\00a0";
+.moonbit-diff-editor .diff-editor-inline-char-delete {
+ background: var(--vscode-diffEditor-removedTextBackground, #ff000066);
}
-.moonbit-diff-editor .diff-editor-unified-deleted-line-number {
+.moonbit-diff-editor .diff-editor-inline-deleted-line-number {
display: flex;
align-items: center;
justify-content: flex-end;
@@ -254,8 +140,8 @@
white-space: nowrap;
}
-.moonbit-diff-editor .diff-editor-unified-deleted-line-number::before {
- content: "−";
+.moonbit-diff-editor .diff-editor-inline-deleted-line-number::before {
color: var(--vscode-editorGutter-deletedBackground, #c84a4a);
font-weight: 700;
+ content: "−";
}
diff --git a/editor/viewer/browser/pkg.generated.mbti b/editor/viewer/browser/pkg.generated.mbti
index 8b271da66..258c3a7d6 100644
--- a/editor/viewer/browser/pkg.generated.mbti
+++ b/editor/viewer/browser/pkg.generated.mbti
@@ -206,9 +206,10 @@ pub(all) struct ViewZone {
mut dom_node : @dom.Element
mut margin_dom_node : @dom.Element?
mut on_dom_node_top : ((Double) -> Unit raise)?
+ mut on_dom_node_layout : ((Bool, Double) -> Unit raise)?
mut on_computed_height : ((Double) -> Unit raise)?
}
-pub fn ViewZone::ViewZone(Int, @dom.Element, after_column? : Int, after_column_affinity? : @model.PositionAffinity, show_in_hidden_areas? : Bool, ignore_hidden_area_source? : String, ordinal? : Int, suppress_mouse_down? : Bool, height_in_lines? : Double, height_in_px? : Double, min_width_in_px? : Double, margin_dom_node? : @dom.Element, on_dom_node_top? : (Double) -> Unit raise, on_computed_height? : (Double) -> Unit raise) -> Self
+pub fn ViewZone::ViewZone(Int, @dom.Element, after_column? : Int, after_column_affinity? : @model.PositionAffinity, show_in_hidden_areas? : Bool, ignore_hidden_area_source? : String, ordinal? : Int, suppress_mouse_down? : Bool, height_in_lines? : Double, height_in_px? : Double, min_width_in_px? : Double, margin_dom_node? : @dom.Element, on_dom_node_top? : (Double) -> Unit raise, on_dom_node_layout? : (Bool, Double) -> Unit raise, on_computed_height? : (Double) -> Unit raise) -> Self
type ViewZoneChangeAccessor
pub fn ViewZoneChangeAccessor::add_zone(Self, ViewZone) -> String
diff --git a/editor/viewer/browser/unified_diff/unified_diff.css b/editor/viewer/browser/unified_diff/unified_diff.css
deleted file mode 100644
index c97c6e525..000000000
--- a/editor/viewer/browser/unified_diff/unified_diff.css
+++ /dev/null
@@ -1,211 +0,0 @@
-.moonbit-unified-diff-surface-hidden {
- display: none !important;
-}
-
-.moonbit-unified-diff {
- box-sizing: border-box;
- width: 100%;
- height: 100%;
- overflow: auto;
- padding: 12px 0 24px;
- color: var(--vscode-editor-foreground);
- background: var(--vscode-editor-background);
- font-family: var(--editor-font-family, monospace);
- font-size: var(--editor-font-size, 13px);
- line-height: var(--editor-line-height, 20px);
- tab-size: var(--editor-tab-size, 2);
-}
-
-.moonbit-unified-diff:focus {
- outline: 1px solid var(--vscode-focusBorder);
- outline-offset: -1px;
-}
-
-.moonbit-unified-diff-hunk + .moonbit-unified-diff-hunk {
- margin-top: 12px;
-}
-
-.moonbit-unified-diff-line {
- display: grid;
- grid-template-columns: 6ch 6ch 2ch minmax(max-content, 1fr);
- min-width: max-content;
-}
-
-.moonbit-unified-diff-line-commentable {
- grid-template-columns: 3ch 6ch 6ch 2ch minmax(max-content, 1fr);
-}
-
-.moonbit-unified-diff-hunk-header {
- padding: 2px 12px;
- border-block: 1px solid var(--vscode-editorWidget-border);
- color: var(--vscode-editorLineNumber-foreground);
- background: color-mix(
- in srgb,
- var(--vscode-editorLineNumber-foreground) 10%,
- transparent
- );
- min-width: max-content;
- user-select: text;
-}
-
-.moonbit-unified-diff-line {
- padding-right: 12px;
- white-space: pre;
-}
-
-.moonbit-unified-diff-line-addition {
- background: color-mix(
- in srgb,
- var(--vscode-editorGutter-addedBackground) 16%,
- transparent
- );
-}
-
-.moonbit-unified-diff-line-deletion {
- background: color-mix(
- in srgb,
- var(--vscode-editorGutter-deletedBackground) 16%,
- transparent
- );
-}
-
-.moonbit-unified-diff-line-number,
-.moonbit-unified-diff-line-marker {
- box-sizing: border-box;
- color: var(--vscode-editorLineNumber-foreground);
- text-align: right;
- user-select: none;
-}
-
-.moonbit-unified-diff-line-number {
- padding: 0 1ch;
- border-right: 1px solid var(--vscode-editorWidget-border);
-}
-
-.moonbit-unified-diff-line-marker {
- padding-right: 0.5ch;
-}
-
-.moonbit-unified-diff-line-addition .moonbit-unified-diff-line-marker {
- color: var(--vscode-editorGutter-addedBackground);
-}
-
-.moonbit-unified-diff-line-deletion .moonbit-unified-diff-line-marker {
- color: var(--vscode-editorGutter-deletedBackground);
-}
-
-.moonbit-unified-diff-line-text {
- min-width: 0;
- font: inherit;
-}
-
-.moonbit-unified-diff-comment-action {
- box-sizing: border-box;
- width: 20px;
- height: 20px;
- margin: 0 auto;
- padding: 0;
- border: 0;
- border-radius: 3px;
- color: var(--vscode-editorLineNumber-foreground);
- background: transparent;
- font: inherit;
- font-weight: 700;
- line-height: 20px;
- cursor: pointer;
- opacity: 0;
-}
-
-.moonbit-unified-diff-line-commentable:hover
- .moonbit-unified-diff-comment-action,
-.moonbit-unified-diff-comment-action:focus {
- color: var(--vscode-button-foreground);
- background: var(--vscode-button-background);
- opacity: 1;
-}
-
-.moonbit-unified-diff-comment-action:focus {
- outline: 1px solid var(--vscode-focusBorder);
- outline-offset: -1px;
-}
-
-.moonbit-unified-diff-comment-form {
- box-sizing: border-box;
- display: flex;
- width: calc(100% - 3ch - 12px);
- min-width: 0;
- flex-wrap: wrap;
- gap: 8px;
- align-items: flex-end;
- margin: 4px 12px 8px 3ch;
- padding: 8px;
- border: 1px solid var(--vscode-editorWidget-border);
- border-radius: 4px;
- background: var(--vscode-editorWidget-background);
- white-space: normal;
-}
-
-.moonbit-unified-diff-comment-form textarea {
- box-sizing: border-box;
- min-width: 0;
- flex: 1 1 240px;
- resize: vertical;
- padding: 5px 7px;
- border: 1px solid var(--vscode-input-border, transparent);
- color: var(--vscode-input-foreground);
- background: var(--vscode-input-background);
- font: inherit;
- line-height: 18px;
-}
-
-.moonbit-unified-diff-comment-form textarea:focus {
- border-color: var(--vscode-focusBorder);
- outline: none;
-}
-
-.moonbit-unified-diff-comment-actions {
- display: flex;
- flex: 0 0 auto;
- gap: 6px;
- margin-left: auto;
-}
-
-.moonbit-unified-diff-comment-cancel,
-.moonbit-unified-diff-comment-submit {
- padding: 4px 8px;
- border: 1px solid var(--vscode-button-border, transparent);
- border-radius: 3px;
- font: inherit;
- cursor: pointer;
-}
-
-.moonbit-unified-diff-comment-cancel {
- color: var(--vscode-button-secondaryForeground);
- background: var(--vscode-button-secondaryBackground);
-}
-
-.moonbit-unified-diff-comment-submit {
- color: var(--vscode-button-foreground);
- background: var(--vscode-button-background);
-}
-
-.moonbit-unified-diff-comment-submit:disabled {
- cursor: default;
- opacity: 0.5;
-}
-
-@media (hover: none) {
- .moonbit-unified-diff-comment-action {
- opacity: 0.7;
- }
-}
-
-.moonbit-unified-diff-empty {
- display: flex;
- min-height: 100%;
- align-items: center;
- justify-content: center;
- padding: 24px;
- color: var(--vscode-editorLineNumber-foreground);
- text-align: center;
-}
diff --git a/editor/viewer/browser/view/editor.css b/editor/viewer/browser/view/editor.css
index 45d605ae2..c05c4629a 100644
--- a/editor/viewer/browser/view/editor.css
+++ b/editor/viewer/browser/view/editor.css
@@ -37,3 +37,17 @@
color: var(--vscode-foreground);
font-size: 14px;
}
+
+/* Generic CodeEditorWidget-owned overview ruler. It sits beside, rather than
+ replacing or delegating to, the ordinary vertical scrollbar. */
+.moonbit-viewer .moonbit-code-overview-ruler {
+ position: absolute;
+ z-index: 10;
+ top: 0;
+ right: 14px;
+ width: 12px;
+ height: calc(100% - 12px);
+ contain: strict;
+ cursor: pointer;
+ touch-action: none;
+}
diff --git a/editor/viewer/browser/view_zones.mbt b/editor/viewer/browser/view_zones.mbt
index eacb3ff14..b762bd122 100644
--- a/editor/viewer/browser/view_zones.mbt
+++ b/editor/viewer/browser/view_zones.mbt
@@ -19,6 +19,7 @@ pub(all) struct ViewZone {
mut dom_node : @rdom.Element
mut margin_dom_node : @rdom.Element?
mut on_dom_node_top : ((Double) -> Unit raise)?
+ mut on_dom_node_layout : ((Bool, Double) -> Unit raise)?
mut on_computed_height : ((Double) -> Unit raise)?
}
@@ -39,6 +40,7 @@ pub fn ViewZone::ViewZone(
min_width_in_px? : Double,
margin_dom_node? : @rdom.Element,
on_dom_node_top? : (Double) -> Unit raise,
+ on_dom_node_layout? : (Bool, Double) -> Unit raise,
on_computed_height? : (Double) -> Unit raise,
) -> ViewZone {
{
@@ -55,6 +57,7 @@ pub fn ViewZone::ViewZone(
dom_node,
margin_dom_node,
on_dom_node_top,
+ on_dom_node_layout,
on_computed_height,
}
}
diff --git a/editor/viewer/common/diff/README.mbt.md b/editor/viewer/common/diff/README.mbt.md
index 2f20aec66..b87500ec1 100644
--- a/editor/viewer/common/diff/README.mbt.md
+++ b/editor/viewer/common/diff/README.mbt.md
@@ -1,47 +1,38 @@
# viewer/common/diff
-Line-diff contracts and the default computer used by quick diff.
-
-This DOM-free package turns two arrays of lines into detailed half-open line
-range mappings. Quick diff consumes those mappings to place gutter
-decorations; the algorithm does not know about models, decorations, or the DOM.
+Renderer-neutral document-diff contracts and the built-in synchronous Core
+Myers provider. The package has no DOM or editor-widget dependency.
```mermaid
flowchart LR
- O["original lines"] --> C["DefaultLinesDiffComputer"]
- M["modified lines"] --> C
- C --> R["LinesDiff.changes"]
- R --> D["DetailedLineRangeMapping
original ↔ modified"]
+ O["original TextSnapshot"] --> P["DocumentDiffProvider"]
+ M["modified TextSnapshot"] --> P
+ P --> C["DocumentDiff.changes
render and navigate"]
+ P --> A["additional_alignments
geometry only"]
```
## Computing a diff
+The built-in provider accepts immutable, LF-normalized snapshots. A line
+change may also contain one-based UTF-16 character mappings.
+
```mbt check
///|
-let line_options : @diff.LinesDiffComputerOptions = {
+let strict_options : @diff.DocumentDiffOptions = {
ignore_trim_whitespace: false,
- max_computation_time_ms: 1000,
- compute_moves: false,
}
///|
-test "an edited line maps one original range onto one modified range" {
- let result = @diff.get_default().compute_diff(
- ["fn main {", " println(1)", "}"],
- ["fn main {", " println(2)", "}"],
- line_options,
- )
- debug_inspect(
- result.changes.map(change => (change.original, change.modified)),
- content=(
- #|[
- #| (
- #| { start_line_number: 2, end_line_number_exclusive: 3 },
- #| { start_line_number: 2, end_line_number_exclusive: 3 },
- #| ),
- #|]
- ),
+test "an edited line maps original and modified ranges" {
+ let result = @diff.get_core_document_diff_provider().compute_diff(
+ TextSnapshot("fn main {\n println(1)\n}"),
+ TextSnapshot("fn main {\n println(2)\n}"),
+ strict_options,
)
+ assert_eq(result.changes.length(), 1)
+ assert_eq(result.changes[0].original, LineRange(2, 3))
+ assert_eq(result.changes[0].modified, LineRange(2, 3))
+ assert_true(result.additional_alignments.is_empty())
}
```
@@ -50,72 +41,43 @@ range. Identical inputs produce no changes.
```mbt check
///|
-test "insertions and identical inputs keep half-open range semantics" {
- let inserted = @diff.get_default().compute_diff(
- ["a", "c"],
- ["a", "b", "c"],
- line_options,
- )
- let identical = @diff.get_default().compute_diff(
- ["a", "b"],
- ["a", "b"],
- line_options,
- )
- debug_inspect(
- (
- inserted.changes.map(change => {
- (change.original.is_empty(), change.original, change.modified)
- }),
- identical.changes.length(),
- ),
- content=(
- #|(
- #| [
- #| (
- #| true,
- #| { start_line_number: 2, end_line_number_exclusive: 2 },
- #| { start_line_number: 2, end_line_number_exclusive: 3 },
- #| ),
- #| ],
- #| 0,
- #|)
- ),
+test "insertions keep half-open line-range semantics" {
+ let inserted = @diff.get_core_document_diff_provider().compute_diff(
+ TextSnapshot("a\nc"),
+ TextSnapshot("a\nb\nc"),
+ strict_options,
)
+ assert_eq(inserted.changes.length(), 1)
+ assert_true(inserted.changes[0].original.is_empty())
+ assert_eq(inserted.changes[0].modified, LineRange(2, 3))
}
```
-## Whitespace policy
+## Whitespace and provider policy
-`ignore_trim_whitespace` decides whether re-indentation is a change.
+`ignore_trim_whitespace` ignores only whitespace at each line's edges. The
+contract deliberately has no fake millisecond timeout, timeout result, or move
+option: providers expose only semantics they implement.
```mbt check
///|
-test "ignore_trim_whitespace can ignore re-indentation" {
- let original = ["fn main {", "println(1)", "}"]
- let modified = ["fn main {", " println(1)", "}"]
- let strict = @diff.get_default().compute_diff(
- original, modified, line_options,
- )
- let lenient = @diff.get_default().compute_diff(original, modified, {
+test "trim whitespace can ignore re-indentation" {
+ let provider = @diff.CoreDocumentDiffProvider()
+ let original = @model.TextSnapshot("fn main {\nprintln(1)\n}")
+ let modified = @model.TextSnapshot("fn main {\n println(1)\n}")
+ let strict = provider.compute_diff(original, modified, strict_options)
+ let lenient = provider.compute_diff(original, modified, {
ignore_trim_whitespace: true,
- max_computation_time_ms: 1000,
- compute_moves: false,
})
- debug_inspect(
- (strict.changes.length(), lenient.changes.length()),
- content=(
- #|(1, 0)
- ),
- )
+ assert_eq(strict.changes.length(), 1)
+ assert_true(lenient.changes.is_empty())
}
```
-## Boundaries and checks
-
-The mapping shapes follow `vs/editor/common/diff/`. Computation delegates to
-`moonbitlang/core/diff`; `max_computation_time_ms` preserves the bounded work
-contract callers depend on. Internal mapping helpers and constructors are not
-part of the package interface.
+External providers implement `DocumentDiffProvider` and may fill
+`additional_alignments` for ignored source rows. `LineRangeMapping`,
+`DetailedLineRangeMapping`, and `RangeMapping` are public constructible values,
+so no viewer-layer adapter is required.
```sh
moon test --target js viewer/common/diff
diff --git a/editor/viewer/common/diff/default_lines_diff_computer.mbt b/editor/viewer/common/diff/default_lines_diff_computer.mbt
index b9b69b610..7212b007f 100644
--- a/editor/viewer/common/diff/default_lines_diff_computer.mbt
+++ b/editor/viewer/common/diff/default_lines_diff_computer.mbt
@@ -1,62 +1,71 @@
-// The viewer's `DefaultLinesDiffComputer` — the API of
-// `vscode/src/vs/editor/common/diff/defaultLinesDiffComputer/
-// defaultLinesDiffComputer.ts` (checked-in vscode submodule) over a different engine.
-//
-// DEVIATION (by decision, 2026-07-09): the computation is delegated to Core's
-// `diff` package (a GNU-diffutils-style bidirectional Myers) instead of porting
-// Monaco's Myers + refinement pipeline. The source method contract, the result
-// types, and the two trivial-input branches are 1:1. Character-level inner
-// changes are computed with a bounded Core-diff scalar pass, but Monaco's
-// refinement heuristics (`optimizeSequenceDiffs`,
-// `extendDiffsToEntireWordIfAppropriate`, `removeShortMatches`,
-// `removeVeryShortMatching*`) are not ported;
-// - move detection (`computeMovedLines`) — the result field is not exposed and
-// `options.compute_moves` is ignored;
-// - the timeout protocol — Core diff's `cutoff` bounds the search *depth*, not
-// wall-clock time, and degrades silently to a correct-but-suboptimal
-// script, so `hit_timeout` is always `false`.
-// `options.max_computation_time_ms == 0` (Monaco's `InfiniteTimeout`) maps
-// to an effectively-unbounded cutoff; any other value keeps Core diff's
-// default `~sqrt(n+m)` heuristic.
-// Line-range boundaries may therefore differ from Monaco's output by
-// tie-breaking among equal adjacent lines; tests assert diff *validity*
-// (round-trip, ordering) and hand-checked fixtures, not byte parity.
-//
-// Parity ledger (source members): `computeDiff` trivial-equal branch (:28)
-// and one-side-empty-document branch (:33) — PORTED; the remainder of
-// `computeDiff` plus `refineDiff` and the assertion harness — N-A (replaced
-// by the Core diff engine per the deviation above).
+// Built-in synchronous document diff provider. It delegates line and scalar
+// matching to moonbitlang/core/diff's Myers implementation. This is an
+// intentional local algorithm choice, not an emulation of Monaco's advanced
+// refinement or timeout/move protocol.
///|
-/// `DefaultLinesDiffComputer` (`defaultLinesDiffComputer.ts:26`), engine
-/// swapped for Core diff (see the file header).
-struct DefaultLinesDiffComputer {
+/// Stateless Core-Myers document diff provider.
+pub struct CoreDocumentDiffProvider {
_unit : Unit
} derive(Eq, Debug)
///|
-fn DefaultLinesDiffComputer::DefaultLinesDiffComputer() -> DefaultLinesDiffComputer {
+pub fn CoreDocumentDiffProvider::CoreDocumentDiffProvider() -> CoreDocumentDiffProvider {
{ _unit: () }
}
///|
-pub fn DefaultLinesDiffComputer::compute_diff(
- self : DefaultLinesDiffComputer,
+fn snapshot_lines(snapshot : @model.TextSnapshot) -> Array[String] {
+ let lines : Array[String] = []
+ for line_number in 1..<=snapshot.line_count() {
+ lines.push(snapshot.get_line_content(line_number))
+ }
+ lines
+}
+
+///|
+pub fn CoreDocumentDiffProvider::compute_diff(
+ self : CoreDocumentDiffProvider,
+ original : @model.TextSnapshot,
+ modified : @model.TextSnapshot,
+ options : DocumentDiffOptions,
+) -> DocumentDiff {
+ self.compute_lines_diff(
+ snapshot_lines(original),
+ snapshot_lines(modified),
+ options,
+ )
+}
+
+///|
+fn CoreDocumentDiffProvider::compute_lines_diff(
+ self : CoreDocumentDiffProvider,
original_lines : Array[String],
modified_lines : Array[String],
- options : LinesDiffComputerOptions,
-) -> LinesDiff {
+ options : DocumentDiffOptions,
+) -> DocumentDiff {
ignore(self)
- // `defaultLinesDiffComputer.ts:28`: a trivially-equal (empty or one-line)
- // input diffs to nothing.
- if original_lines.length() <= 1 && original_lines == modified_lines {
- return { changes: [], hit_timeout: false }
+ // Normalize before both equality and empty-document handling. Otherwise a
+ // whitespace-only logical line and an empty line incorrectly remain a
+ // visible change when trim whitespace is ignored.
+ let old_input = if options.ignore_trim_whitespace {
+ original_lines.map(line => line.trim().to_owned())
+ } else {
+ original_lines
+ }
+ let new_input = if options.ignore_trim_whitespace {
+ modified_lines.map(line => line.trim().to_owned())
+ } else {
+ modified_lines
+ }
+ if old_input == new_input {
+ return { changes: [], additional_alignments: [] }
}
- // `defaultLinesDiffComputer.ts:33`: one side being the empty document maps
- // to a single whole-document change.
- if (original_lines.length() == 1 && original_lines[0].length() == 0) ||
- (modified_lines.length() == 1 && modified_lines[0].length() == 0) {
+ // Preserve Monaco's useful empty-document shape: its one logical empty line
+ // participates in a single whole-document change.
+ if (old_input.length() == 1 && old_input[0].length() == 0) ||
+ (new_input.length() == 1 && new_input[0].length() == 0) {
return {
changes: [
DetailedLineRangeMapping(
@@ -67,32 +76,19 @@ pub fn DefaultLinesDiffComputer::compute_diff(
modified_lines,
LineRange(1, original_lines.length() + 1),
LineRange(1, modified_lines.length() + 1),
+ options.ignore_trim_whitespace,
),
),
],
- hit_timeout: false,
+ additional_alignments: [],
}
}
- // Engine: Core diff over the (optionally trimmed) line arrays. Trimming
- // before hashing is how the source's `ignoreTrimWhitespace` line equality
- // behaves at line granularity (`defaultLinesDiffComputer.ts:60`).
- let old_input = if options.ignore_trim_whitespace {
- original_lines.map(l => l.trim().to_owned())
- } else {
- original_lines
- }
- let new_input = if options.ignore_trim_whitespace {
- modified_lines.map(l => l.trim().to_owned())
- } else {
- modified_lines
- }
- let cutoff : Int? = if options.max_computation_time_ms == 0 {
- Some(0x7FFFFFFF)
- } else {
- None
- }
- let d = @core_diff.Diff::Diff(old=old_input[:], new=new_input[:], cutoff?)
+ let d = @core_diff.Diff::Diff(
+ old=old_input[:],
+ new=new_input[:],
+ algorithm=Myers,
+ )
// Convert the flat edit script into hunks: each maximal run of
// consecutive `Delete`/`Insert` edits (separated by `Equal` runs) is one
@@ -146,18 +142,40 @@ pub fn DefaultLinesDiffComputer::compute_diff(
original,
modified,
compute_inner_changes(
- original_lines, modified_lines, original, modified,
+ original_lines,
+ modified_lines,
+ original,
+ modified,
+ options.ignore_trim_whitespace,
),
),
)
}
}
}
- { changes, hit_timeout: false }
+ { changes, additional_alignments: [] }
+}
+
+///|
+pub impl DocumentDiffProvider for CoreDocumentDiffProvider with fn compute_diff(
+ self,
+ original,
+ modified,
+ options,
+) {
+ self.compute_diff(original, modified, options)
+}
+
+///|
+pub impl DocumentDiffProvider for CoreDocumentDiffProvider with fn on_did_change(
+ _self,
+ _listener,
+) {
+ @base_common.Disposable::from(() => ())
}
///|
-pub extend DefaultLinesDiffComputer with Debug::{to_repr}
+pub extend CoreDocumentDiffProvider with Debug::{to_repr}
///|
-pub extend DefaultLinesDiffComputer with Eq::{equal, not_equal}
+pub extend CoreDocumentDiffProvider with Eq::{equal, not_equal}
diff --git a/editor/viewer/common/diff/default_lines_diff_computer_wbtest.mbt b/editor/viewer/common/diff/default_lines_diff_computer_wbtest.mbt
index c53609a09..65b8f604c 100644
--- a/editor/viewer/common/diff/default_lines_diff_computer_wbtest.mbt
+++ b/editor/viewer/common/diff/default_lines_diff_computer_wbtest.mbt
@@ -1,4 +1,4 @@
-// White-box behavior tests for the Core-diff-backed `DefaultLinesDiffComputer`.
+// White-box behavior tests for `CoreDocumentDiffProvider`.
//
// The engine deviates from Monaco's by design (see the impl file header), so
// unlike the `*_reference_test.mbt` suites these do NOT byte-compare against
@@ -11,19 +11,47 @@
// plus exact hunks on fixtures where the minimal diff is unambiguous.
///|
-let default_options : LinesDiffComputerOptions = {
- ignore_trim_whitespace: false,
- max_computation_time_ms: 0,
- compute_moves: false,
-}
+let default_options : DocumentDiffOptions = { ignore_trim_whitespace: false }
///|
fn compute(
original : Array[String],
modified : Array[String],
- options? : LinesDiffComputerOptions = default_options,
-) -> LinesDiff {
- get_default().compute_diff(original, modified, options)
+ options? : DocumentDiffOptions = default_options,
+) -> DocumentDiff {
+ get_core_document_diff_provider().compute_diff(
+ TextSnapshot(original.join("\n")),
+ TextSnapshot(modified.join("\n")),
+ options,
+ )
+}
+
+///|
+fn compute_with_provider(
+ provider : &DocumentDiffProvider,
+ original : @model.TextSnapshot,
+ modified : @model.TextSnapshot,
+ options : DocumentDiffOptions,
+) -> DocumentDiff raise {
+ provider.compute_diff(original, modified, options)
+}
+
+///|
+test "Core provider satisfies the public provider contract" {
+ let core = CoreDocumentDiffProvider()
+ let provider : &DocumentDiffProvider = core
+ let result = try! compute_with_provider(
+ provider,
+ TextSnapshot("same\nbefore"),
+ TextSnapshot("same\nafter"),
+ default_options,
+ )
+ assert_eq(result.changes.length(), 1)
+ assert_true(result.additional_alignments.is_empty())
+ let notifications = Ref(0)
+ let subscription = provider.on_did_change(() => notifications.val += 1)
+ subscription.dispose()
+ assert_eq(notifications.val, 0)
}
///|
@@ -116,7 +144,7 @@ fn apply_changes(
fn assert_valid(
original : Array[String],
modified : Array[String],
- result : LinesDiff,
+ result : DocumentDiff,
) -> Unit raise {
for i in 1.. ())
+}
+
+///|
+test "an external package can implement the provider and construct mappings" {
+ let provider : &@diff.DocumentDiffProvider = ExternalDocumentDiffProvider::{
+
+ }
+ let result = try! provider.compute_diff(
+ TextSnapshot("a"),
+ TextSnapshot("bc"),
+ { ignore_trim_whitespace: false },
+ )
+ assert_eq(result.changes.length(), 1)
+ assert_eq(result.additional_alignments.length(), 1)
+ assert_eq(result.changes[0].inner_changes.unwrap()[0].modified.end_column, 3)
+}
diff --git a/editor/viewer/common/diff/inner_changes.mbt b/editor/viewer/common/diff/inner_changes.mbt
index f4ddcb879..4510a090b 100644
--- a/editor/viewer/common/diff/inner_changes.mbt
+++ b/editor/viewer/common/diff/inner_changes.mbt
@@ -15,6 +15,12 @@ priv struct InnerDiffSequence {
boundaries : Array[@base_common.Position]
}
+///|
+priv struct InnerDiffLine {
+ values : Array[Int]
+ boundaries : Array[@base_common.Position]
+}
+
///|
fn line_range_code_units(
lines : Array[String],
@@ -45,26 +51,81 @@ fn empty_line_range_anchor(
}
}
+///|
+fn flatten_inner_diff_line(
+ line : String,
+ line_number : Int,
+ ignore_trim_whitespace : Bool,
+) -> InnerDiffLine {
+ let values : Array[Int] = []
+ let whitespace : Array[Bool] = []
+ let starts : Array[Int] = []
+ let ends : Array[Int] = []
+ let mut column = 1
+ for character in line {
+ values.push(character.to_int())
+ whitespace.push(character.is_whitespace())
+ starts.push(column)
+ column += character.utf16_len()
+ ends.push(column)
+ }
+ let mut start = 0
+ let mut end = values.length()
+ if ignore_trim_whitespace {
+ while start < end && whitespace[start] {
+ start += 1
+ }
+ while end > start && whitespace[end - 1] {
+ end -= 1
+ }
+ }
+ let normalized : Array[Int] = []
+ let anchor_column = if start < end { starts[start] } else { 1 }
+ let boundaries : Array[@base_common.Position] = [
+ Position(line_number, anchor_column),
+ ]
+ for index in start.. InnerDiffSequence {
+ if range.is_empty() {
+ return {
+ values: [],
+ boundaries: [empty_line_range_anchor(lines, range.start_line_number)],
+ }
+ }
+ let line_sequences : Array[InnerDiffLine] = []
+ for line_number in range.start_line_number.. Array[RangeMapping]? {
let original_length = line_range_code_units(original_lines, original_range)
let modified_length = line_range_code_units(modified_lines, modified_range)
@@ -98,8 +160,12 @@ fn compute_inner_changes(
original_length > MaxInnerDiffCodeUnits - modified_length {
return None
}
- let original = flatten_inner_diff_sequence(original_lines, original_range)
- let modified = flatten_inner_diff_sequence(modified_lines, modified_range)
+ let original = flatten_inner_diff_sequence(
+ original_lines, original_range, ignore_trim_whitespace,
+ )
+ let modified = flatten_inner_diff_sequence(
+ modified_lines, modified_range, ignore_trim_whitespace,
+ )
let edits = @core_diff.Diff::Diff(
old=original.values[:],
new=modified.values[:],
diff --git a/editor/viewer/common/diff/lines_diff_computer.mbt b/editor/viewer/common/diff/lines_diff_computer.mbt
index f50b27503..d5752f61c 100644
--- a/editor/viewer/common/diff/lines_diff_computer.mbt
+++ b/editor/viewer/common/diff/lines_diff_computer.mbt
@@ -1,45 +1,50 @@
-// Port of `vscode/src/vs/editor/common/diff/linesDiffComputer.ts` (vscode
-// checked-in submodule): the computer
-// options and result shape. The sole local engine is exposed concretely from
-// `default_lines_diff_computer.mbt` instead of retaining the source interface
-// as an unused runtime abstraction.
-//
-// Parity ledger (source members):
-// - `ILinesDiffComputer.computeDiff` — PORTED as
-// `DefaultLinesDiffComputer::compute_diff`.
-// - `ILinesDiffComputerOptions` — PORTED; `extendToSubwords?` — DEFERRED
-// (consumed only by the char-level refinement, which is not ported).
-// - `LinesDiff` changes/hitTimeout — PORTED. Move detection and its result
-// shape are deferred because the viewer's computer does not implement it.
+// Renderer-neutral document diff contract. Unlike the old partial Monaco
+// `ILinesDiffComputer` facade, this API describes only behavior implemented by
+// this repository: synchronous calculation, trim-whitespace policy, rendered
+// changes, and geometry-only alignments.
///|
-/// `ILinesDiffComputerOptions` (`linesDiffComputer.ts:12`).
-pub(all) struct LinesDiffComputerOptions {
+/// Options shared by all synchronous document diff providers.
+pub(all) struct DocumentDiffOptions {
ignore_trim_whitespace : Bool
- /// `0` disables the computation budget entirely (Monaco's
- /// `InfiniteTimeout`).
- max_computation_time_ms : Int
- compute_moves : Bool
} derive(Eq, Debug)
///|
-/// `LinesDiff` (`linesDiffComputer.ts:19`).
-pub struct LinesDiff {
+/// A complete document diff. `changes` drive decorations, overview markers,
+/// navigation, and alignment. `additional_alignments` affect geometry only;
+/// providers use them for source rows deliberately omitted from the visible
+/// diff, such as ignored comments.
+pub(all) struct DocumentDiff {
changes : Array[DetailedLineRangeMapping]
- /// Indicates if the time out was reached. In that case, the diffs might be
- /// an approximation and the user should be asked to rerun the diff with
- /// more time.
- priv hit_timeout : Bool
+ additional_alignments : Array[LineRangeMapping]
} derive(Eq, Debug)
///|
-pub extend LinesDiff with Debug::{to_repr}
+/// Synchronous, renderer-neutral document diff provider. Providers may raise
+/// when calculation fails; the owning diff ViewModel is responsible for
+/// converting that failure into its `Failed` state.
+pub(open) trait DocumentDiffProvider {
+ fn compute_diff(
+ Self,
+ @model.TextSnapshot,
+ @model.TextSnapshot,
+ DocumentDiffOptions,
+ ) -> DocumentDiff raise
+
+ /// Notifies the owner that provider configuration changed and the current
+ /// model pair must be recomputed. Immutable providers return a no-op
+ /// disposable and never invoke the listener.
+ fn on_did_change(Self, () -> Unit) -> @base_common.Disposable
+}
+
+///|
+pub extend DocumentDiff with Debug::{to_repr}
///|
-pub extend LinesDiff with Eq::{equal, not_equal}
+pub extend DocumentDiff with Eq::{equal, not_equal}
///|
-pub extend LinesDiffComputerOptions with Debug::{to_repr}
+pub extend DocumentDiffOptions with Debug::{to_repr}
///|
-pub extend LinesDiffComputerOptions with Eq::{equal, not_equal}
+pub extend DocumentDiffOptions with Eq::{equal, not_equal}
diff --git a/editor/viewer/common/diff/lines_diff_computers.mbt b/editor/viewer/common/diff/lines_diff_computers.mbt
index 0f196773d..c3d309386 100644
--- a/editor/viewer/common/diff/lines_diff_computers.mbt
+++ b/editor/viewer/common/diff/lines_diff_computers.mbt
@@ -1,13 +1,8 @@
-// Port of `vscode/src/vs/editor/common/diff/linesDiffComputers.ts` (vscode
-// checked-in submodule): the computer
-// registry.
-//
-// Parity ledger: `getDefault` — PORTED (package-level fn; the viewer has no
-// namespace object). The other source registry entries are N-A; this package
-// exposes one Core-diff-backed computer.
+// Built-in provider factory. Keeping construction explicit lets callers own a
+// concrete provider value and coerce it to `&DocumentDiffProvider` only at the
+// DiffEditor boundary.
///|
-/// `linesDiffComputers.getDefault()` (`linesDiffComputers.ts:11`).
-pub fn get_default() -> DefaultLinesDiffComputer {
- DefaultLinesDiffComputer()
+pub fn get_core_document_diff_provider() -> CoreDocumentDiffProvider {
+ CoreDocumentDiffProvider()
}
diff --git a/editor/viewer/common/diff/moon.pkg b/editor/viewer/common/diff/moon.pkg
index 9057c4e3f..444b5d161 100644
--- a/editor/viewer/common/diff/moon.pkg
+++ b/editor/viewer/common/diff/moon.pkg
@@ -1,4 +1,5 @@
import {
"moonbitlang/editor/base/common" @base_common,
"moonbitlang/core/diff" @core_diff,
+ "moonbitlang/editor/viewer/common/model",
}
diff --git a/editor/viewer/common/diff/pkg.generated.mbti b/editor/viewer/common/diff/pkg.generated.mbti
index 82de4a9e5..b1a301391 100644
--- a/editor/viewer/common/diff/pkg.generated.mbti
+++ b/editor/viewer/common/diff/pkg.generated.mbti
@@ -4,47 +4,74 @@ package "moonbitlang/editor/viewer/common/diff"
import {
"moonbitlang/core/debug",
"moonbitlang/editor/base/common",
+ "moonbitlang/editor/viewer/common/model",
}
// Values
-pub fn get_default() -> DefaultLinesDiffComputer
+pub fn get_core_document_diff_provider() -> CoreDocumentDiffProvider
// Errors
// Types and methods
-type DefaultLinesDiffComputer derive(Eq, @debug.Debug)
-pub fn DefaultLinesDiffComputer::compute_diff(Self, Array[String], Array[String], LinesDiffComputerOptions) -> LinesDiff
-pub fn DefaultLinesDiffComputer::equal(Self, Self) -> Bool
-pub fn DefaultLinesDiffComputer::not_equal(Self, Self) -> Bool
-pub fn DefaultLinesDiffComputer::to_repr(Self) -> @debug.Repr
+pub struct CoreDocumentDiffProvider {
+ _unit : Unit
+} derive(Eq, @debug.Debug)
+pub fn CoreDocumentDiffProvider::CoreDocumentDiffProvider() -> Self
+pub fn CoreDocumentDiffProvider::compute_diff(Self, @model.TextSnapshot, @model.TextSnapshot, DocumentDiffOptions) -> DocumentDiff
+pub fn CoreDocumentDiffProvider::equal(Self, Self) -> Bool
+pub fn CoreDocumentDiffProvider::not_equal(Self, Self) -> Bool
+pub fn CoreDocumentDiffProvider::to_repr(Self) -> @debug.Repr
+pub impl DocumentDiffProvider for CoreDocumentDiffProvider
-pub struct DetailedLineRangeMapping {
+pub(all) struct DetailedLineRangeMapping {
original : @common.LineRange
modified : @common.LineRange
- // private fields
+ inner_changes : Array[RangeMapping]?
} derive(Eq, @debug.Debug)
+pub fn DetailedLineRangeMapping::DetailedLineRangeMapping(@common.LineRange, @common.LineRange, Array[RangeMapping]?) -> Self
pub fn DetailedLineRangeMapping::equal(Self, Self) -> Bool
pub fn DetailedLineRangeMapping::get_inner_changes(Self) -> Array[(@common.Range, @common.Range)]?
+pub fn DetailedLineRangeMapping::line_mapping(Self) -> LineRangeMapping
pub fn DetailedLineRangeMapping::not_equal(Self, Self) -> Bool
pub fn DetailedLineRangeMapping::to_repr(Self) -> @debug.Repr
-pub struct LinesDiff {
+pub(all) struct DocumentDiff {
changes : Array[DetailedLineRangeMapping]
- // private fields
+ additional_alignments : Array[LineRangeMapping]
} derive(Eq, @debug.Debug)
-pub fn LinesDiff::equal(Self, Self) -> Bool
-pub fn LinesDiff::not_equal(Self, Self) -> Bool
-pub fn LinesDiff::to_repr(Self) -> @debug.Repr
+pub fn DocumentDiff::equal(Self, Self) -> Bool
+pub fn DocumentDiff::not_equal(Self, Self) -> Bool
+pub fn DocumentDiff::to_repr(Self) -> @debug.Repr
-pub(all) struct LinesDiffComputerOptions {
+pub(all) struct DocumentDiffOptions {
ignore_trim_whitespace : Bool
- max_computation_time_ms : Int
- compute_moves : Bool
} derive(Eq, @debug.Debug)
-pub fn LinesDiffComputerOptions::equal(Self, Self) -> Bool
-pub fn LinesDiffComputerOptions::not_equal(Self, Self) -> Bool
-pub fn LinesDiffComputerOptions::to_repr(Self) -> @debug.Repr
+pub fn DocumentDiffOptions::equal(Self, Self) -> Bool
+pub fn DocumentDiffOptions::not_equal(Self, Self) -> Bool
+pub fn DocumentDiffOptions::to_repr(Self) -> @debug.Repr
+
+pub(all) struct LineRangeMapping {
+ original : @common.LineRange
+ modified : @common.LineRange
+} derive(Eq, @debug.Debug)
+pub fn LineRangeMapping::LineRangeMapping(@common.LineRange, @common.LineRange) -> Self
+pub fn LineRangeMapping::equal(Self, Self) -> Bool
+pub fn LineRangeMapping::not_equal(Self, Self) -> Bool
+pub fn LineRangeMapping::to_repr(Self) -> @debug.Repr
+
+pub(all) struct RangeMapping {
+ original : @common.Range
+ modified : @common.Range
+} derive(Eq, @debug.Debug)
+pub fn RangeMapping::RangeMapping(@common.Range, @common.Range) -> Self
+pub fn RangeMapping::equal(Self, Self) -> Bool
+pub fn RangeMapping::not_equal(Self, Self) -> Bool
+pub fn RangeMapping::to_repr(Self) -> @debug.Repr
// Type aliases
// Traits
+pub(open) trait DocumentDiffProvider {
+ fn compute_diff(Self, @model.TextSnapshot, @model.TextSnapshot, DocumentDiffOptions) -> DocumentDiff raise
+ fn on_did_change(Self, () -> Unit) -> @common.Disposable
+}
diff --git a/editor/viewer/common/diff/range_mapping.mbt b/editor/viewer/common/diff/range_mapping.mbt
index 674ae6b26..c4bf07e82 100644
--- a/editor/viewer/common/diff/range_mapping.mbt
+++ b/editor/viewer/common/diff/range_mapping.mbt
@@ -1,22 +1,39 @@
-// Port of `vscode/src/vs/editor/common/diff/rangeMapping.ts` (vscode
-// checked-in submodule): the mapping
-// types a lines-diff result is expressed in.
-//
-// Parity ledger (source members):
-// - `LineRangeMapping` — DEFERRED (the current computer emits detailed line
-// mappings directly, so the base-only mapping has no consumer).
-// - `DetailedLineRangeMapping` — PORTED as a flat struct (MoonBit has no
-// class inheritance; it repeats `original`/`modified` beside
-// `inner_changes`). The default computer now fills character-level mappings
-// and exposes them as public range pairs; `toTextEdit`/`fromRangeMappings`/
-// `withInnerChangesFromLineRanges` remain DEFERRED (need TextEdit /
-// `LineRange.fromRangeInclusive` / `toRangeMapping`).
-// - `RangeMapping` originalRange/modifiedRange/ctor/toString/flip/join —
-// PORTED. Statics `fromEdit`/`fromEditJoin`/`toTextEdit` — DEFERRED (no
-// TextEdit type); `join(list)`/`assertSorted` — DEFERRED (no consumer).
-// - `lineRangeMappingFromRangeMappings`/`getLineRangeMapping` — DEFERRED
-// (only the char-level refinement pipeline builds line mappings from range
-// mappings; the viewer's computer emits line mappings directly).
+// Renderer-neutral mapping values used by both the built-in Core provider and
+// external providers. They intentionally expose their representation: a
+// provider package must be able to construct validated output without a
+// viewer-layer adapter.
+
+///|
+/// Maps a half-open line range in the original document to one in the
+/// modified document.
+pub(all) struct LineRangeMapping {
+ original : @base_common.LineRange
+ modified : @base_common.LineRange
+} derive(Eq, Debug)
+
+///|
+pub fn LineRangeMapping::LineRangeMapping(
+ original : @base_common.LineRange,
+ modified : @base_common.LineRange,
+) -> LineRangeMapping {
+ { original, modified }
+}
+
+///|
+/// Maps a one-based UTF-16 range in the original document to one in the
+/// modified document.
+pub(all) struct RangeMapping {
+ original : @base_common.Range
+ modified : @base_common.Range
+} derive(Eq, Debug)
+
+///|
+pub fn RangeMapping::RangeMapping(
+ original : @base_common.Range,
+ modified : @base_common.Range,
+) -> RangeMapping {
+ { original, modified }
+}
///|
/// Maps a line range in the original text model to a line range in the
@@ -24,53 +41,53 @@
/// mappings — `DetailedLineRangeMapping` (`rangeMapping.ts:196`). The source
/// class extends `LineRangeMapping`; MoonBit has no inheritance, so the
/// struct is flat and repeats the two line ranges.
-pub struct DetailedLineRangeMapping {
+pub(all) struct DetailedLineRangeMapping {
original : @base_common.LineRange
modified : @base_common.LineRange
/// If inner changes have not been computed, this is `None`. Otherwise it
/// represents the character-level diff in this line range.
- priv inner_changes : Array[RangeMapping]?
+ inner_changes : Array[RangeMapping]?
} derive(Eq, Debug)
///|
-fn DetailedLineRangeMapping::DetailedLineRangeMapping(
- original_range : @base_common.LineRange,
- modified_range : @base_common.LineRange,
+pub fn DetailedLineRangeMapping::DetailedLineRangeMapping(
+ original : @base_common.LineRange,
+ modified : @base_common.LineRange,
inner_changes : Array[RangeMapping]?,
) -> DetailedLineRangeMapping {
- { original: original_range, modified: modified_range, inner_changes }
+ { original, modified, inner_changes }
}
///|
/// Character-level changes inside this line mapping, or `None` when the
-/// refinement budget was exceeded. Returning public range pairs keeps the
-/// package-private `RangeMapping` representation out of the public API while
-/// allowing DiffViewer to render the computed refinement.
+/// refinement budget was exceeded. This pair projection is convenient for
+/// consumers that do not need the named `RangeMapping` fields.
pub fn DetailedLineRangeMapping::get_inner_changes(
self : DetailedLineRangeMapping,
) -> Array[(@base_common.Range, @base_common.Range)]? {
self.inner_changes.map(changes => {
- changes.map(change => (change.original_range, change.modified_range))
+ changes.map(change => (change.original, change.modified))
})
}
///|
-/// Maps a range in the original text model to a range in the modified text
-/// model — `RangeMapping` (`rangeMapping.ts:243`).
-priv struct RangeMapping {
- /// The original range.
- original_range : @base_common.Range
- /// The modified range.
- modified_range : @base_common.Range
-} derive(Eq, Debug)
+pub fn DetailedLineRangeMapping::line_mapping(
+ self : DetailedLineRangeMapping,
+) -> LineRangeMapping {
+ LineRangeMapping(self.original, self.modified)
+}
///|
-fn RangeMapping::RangeMapping(
- original_range : @base_common.Range,
- modified_range : @base_common.Range,
-) -> RangeMapping {
- { original_range, modified_range }
-}
+pub extend LineRangeMapping with Debug::{to_repr}
+
+///|
+pub extend LineRangeMapping with Eq::{equal, not_equal}
+
+///|
+pub extend RangeMapping with Debug::{to_repr}
+
+///|
+pub extend RangeMapping with Eq::{equal, not_equal}
///|
pub extend DetailedLineRangeMapping with Debug::{to_repr}
diff --git a/editor/viewer/common/unified_diff/exports.mbt b/editor/viewer/common/unified_diff/exports.mbt
deleted file mode 100644
index 8556d8619..000000000
--- a/editor/viewer/common/unified_diff/exports.mbt
+++ /dev/null
@@ -1,169 +0,0 @@
-// Deliberate external/staged contracts for the full-diff renderer planned as
-// this package's first production consumer. Keeping the surface here makes the
-// temporary analyzer exception explicit while the renderer lands separately.
-
-///|
-/// One line in a unified diff hunk.
-pub(all) enum UnifiedDiffLineKind {
- Context
- Deletion
- Addition
-} derive(Eq, Debug)
-
-///|
-/// A line carries the number for each side on which it exists. Context lines
-/// have both, deletions only the original number, and additions only the
-/// modified number.
-pub(all) struct UnifiedDiffLine {
- kind : UnifiedDiffLineKind
- original_line_number : Int?
- modified_line_number : Int?
- text : String
-} derive(Eq, Debug)
-
-///|
-/// One review comment submitted from a rendered unified-diff line. The line
-/// retains both number domains so a host can attach additions/context to the
-/// working document and deletions to the original revision without depending
-/// on renderer DOM attributes.
-pub(all) struct UnifiedDiffLineComment {
- line : UnifiedDiffLine
- comment : String
-} derive(Eq, Debug)
-
-///|
-/// One context-bounded unified diff hunk. Start numbers follow unified-diff
-/// notation: an empty side is anchored at the preceding line (zero at the
-/// beginning of a file).
-pub(all) struct UnifiedDiffHunk {
- original_start_line_number : Int
- original_line_count : Int
- modified_start_line_number : Int
- modified_line_count : Int
- lines : Array[UnifiedDiffLine]
-} derive(Eq, Debug)
-
-///|
-/// One algorithm-neutral line change. Ranges are 1-based and half-open;
-/// an empty original range is an insertion and an empty modified range is a
-/// deletion. Algorithms must return changes in document order.
-pub(all) struct UnifiedDiffChange {
- original_start_line_number : Int
- original_end_line_number_exclusive : Int
- modified_start_line_number : Int
- modified_end_line_number_exclusive : Int
-} derive(Eq, Debug)
-
-///|
-/// Options that belong to line matching rather than hunk presentation.
-pub(all) struct UnifiedDiffAlgorithmOptions {
- ignore_trim_whitespace : Bool
- max_computation_time_ms : Int
-} derive(Eq, Debug)
-
-///|
-/// Replaceable line-matching boundary. The unified-hunk model depends only on
-/// `UnifiedDiffChange`, so a future algorithm needs only this small adapter.
-struct UnifiedDiffAlgorithm {
- compute_changes_callback : (
- Array[String],
- Array[String],
- UnifiedDiffAlgorithmOptions,
- ) -> Array[UnifiedDiffChange]
-}
-
-///|
-/// Creates an algorithm adapter from a line-change callback.
-pub fn UnifiedDiffAlgorithm::UnifiedDiffAlgorithm(
- compute_changes~ : (Array[String], Array[String], UnifiedDiffAlgorithmOptions) -> Array[
- UnifiedDiffChange,
- ],
-) -> UnifiedDiffAlgorithm {
- { compute_changes_callback: compute_changes }
-}
-
-///|
-/// The current adapter uses the editor's existing diff engine, keeping full
-/// and quick diff aligned without coupling the hunk builder to that engine.
-pub fn default_unified_diff_algorithm() -> UnifiedDiffAlgorithm {
- UnifiedDiffAlgorithm(compute_changes=(original_lines, modified_lines, options) => {
- @diff.get_default().compute_diff(original_lines, modified_lines, {
- ignore_trim_whitespace: options.ignore_trim_whitespace,
- max_computation_time_ms: options.max_computation_time_ms,
- compute_moves: false,
- }).changes.map(change => {
- original_start_line_number: change.original.start_line_number,
- original_end_line_number_exclusive: change.original.end_line_number_exclusive,
- modified_start_line_number: change.modified.start_line_number,
- modified_end_line_number_exclusive: change.modified.end_line_number_exclusive,
- })
- })
-}
-
-///|
-/// Compute context-bounded unified diff hunks from two arrays of lines.
-///
-/// Both `[]` and the editor's `[""]` representation are treated as an empty
-/// document before invoking the algorithm.
-///
-/// By default, line mapping comes from the editor's existing diff engine so a
-/// future full-diff view and the current quick-diff gutter agree. Pass an
-/// alternate `algorithm` to replace matching without changing hunk rendering.
-pub fn compute_unified_diff(
- original_lines : Array[String],
- modified_lines : Array[String],
- context_lines? : Int = 3,
- ignore_trim_whitespace? : Bool = false,
- max_computation_time_ms? : Int = 1000,
- algorithm? : UnifiedDiffAlgorithm = default_unified_diff_algorithm(),
-) -> Array[UnifiedDiffHunk] {
- let context_lines = context_lines.max(0)
- let original_lines = canonical_diff_lines(original_lines)
- let modified_lines = canonical_diff_lines(modified_lines)
- let changes = algorithm.compute_changes(original_lines, modified_lines, {
- ignore_trim_whitespace,
- max_computation_time_ms,
- })
- hunk_windows(
- changes,
- original_lines.length(),
- modified_lines.length(),
- context_lines,
- ).map(window => window.to_hunk(original_lines, modified_lines))
-}
-
-///|
-pub extend UnifiedDiffAlgorithmOptions with Debug::{to_repr}
-
-///|
-pub extend UnifiedDiffAlgorithmOptions with Eq::{equal, not_equal}
-
-///|
-pub extend UnifiedDiffChange with Debug::{to_repr}
-
-///|
-pub extend UnifiedDiffChange with Eq::{equal, not_equal}
-
-///|
-pub extend UnifiedDiffHunk with Debug::{to_repr}
-
-///|
-pub extend UnifiedDiffHunk with Eq::{equal, not_equal}
-
-///|
-pub extend UnifiedDiffLine with Debug::{to_repr}
-
-///|
-pub extend UnifiedDiffLine with Eq::{equal, not_equal}
-
-///|
-pub extend UnifiedDiffLineComment with Debug::{to_repr}
-
-///|
-pub extend UnifiedDiffLineComment with Eq::{equal, not_equal}
-
-///|
-pub extend UnifiedDiffLineKind with Debug::{to_repr}
-
-///|
-pub extend UnifiedDiffLineKind with Eq::{equal, not_equal}
diff --git a/editor/viewer/common/unified_diff/moon.pkg b/editor/viewer/common/unified_diff/moon.pkg
deleted file mode 100644
index 214bae469..000000000
--- a/editor/viewer/common/unified_diff/moon.pkg
+++ /dev/null
@@ -1,3 +0,0 @@
-import {
- "moonbitlang/editor/viewer/common/diff",
-}
diff --git a/editor/viewer/common/unified_diff/pkg.generated.mbti b/editor/viewer/common/unified_diff/pkg.generated.mbti
deleted file mode 100644
index 4a6013384..000000000
--- a/editor/viewer/common/unified_diff/pkg.generated.mbti
+++ /dev/null
@@ -1,77 +0,0 @@
-// Generated using `moon info`, DON'T EDIT IT
-package "moonbitlang/editor/viewer/common/unified_diff"
-
-import {
- "moonbitlang/core/debug",
-}
-
-// Values
-pub fn compute_unified_diff(Array[String], Array[String], context_lines? : Int, ignore_trim_whitespace? : Bool, max_computation_time_ms? : Int, algorithm? : UnifiedDiffAlgorithm) -> Array[UnifiedDiffHunk]
-
-pub fn default_unified_diff_algorithm() -> UnifiedDiffAlgorithm
-
-// Errors
-
-// Types and methods
-type UnifiedDiffAlgorithm
-pub fn UnifiedDiffAlgorithm::UnifiedDiffAlgorithm(compute_changes~ : (Array[String], Array[String], UnifiedDiffAlgorithmOptions) -> Array[UnifiedDiffChange]) -> Self
-
-pub(all) struct UnifiedDiffAlgorithmOptions {
- ignore_trim_whitespace : Bool
- max_computation_time_ms : Int
-} derive(Eq, @debug.Debug)
-pub fn UnifiedDiffAlgorithmOptions::equal(Self, Self) -> Bool
-pub fn UnifiedDiffAlgorithmOptions::not_equal(Self, Self) -> Bool
-pub fn UnifiedDiffAlgorithmOptions::to_repr(Self) -> @debug.Repr
-
-pub(all) struct UnifiedDiffChange {
- original_start_line_number : Int
- original_end_line_number_exclusive : Int
- modified_start_line_number : Int
- modified_end_line_number_exclusive : Int
-} derive(Eq, @debug.Debug)
-pub fn UnifiedDiffChange::equal(Self, Self) -> Bool
-pub fn UnifiedDiffChange::not_equal(Self, Self) -> Bool
-pub fn UnifiedDiffChange::to_repr(Self) -> @debug.Repr
-
-pub(all) struct UnifiedDiffHunk {
- original_start_line_number : Int
- original_line_count : Int
- modified_start_line_number : Int
- modified_line_count : Int
- lines : Array[UnifiedDiffLine]
-} derive(Eq, @debug.Debug)
-pub fn UnifiedDiffHunk::equal(Self, Self) -> Bool
-pub fn UnifiedDiffHunk::not_equal(Self, Self) -> Bool
-pub fn UnifiedDiffHunk::to_repr(Self) -> @debug.Repr
-
-pub(all) struct UnifiedDiffLine {
- kind : UnifiedDiffLineKind
- original_line_number : Int?
- modified_line_number : Int?
- text : String
-} derive(Eq, @debug.Debug)
-pub fn UnifiedDiffLine::equal(Self, Self) -> Bool
-pub fn UnifiedDiffLine::not_equal(Self, Self) -> Bool
-pub fn UnifiedDiffLine::to_repr(Self) -> @debug.Repr
-
-pub(all) struct UnifiedDiffLineComment {
- line : UnifiedDiffLine
- comment : String
-} derive(Eq, @debug.Debug)
-pub fn UnifiedDiffLineComment::equal(Self, Self) -> Bool
-pub fn UnifiedDiffLineComment::not_equal(Self, Self) -> Bool
-pub fn UnifiedDiffLineComment::to_repr(Self) -> @debug.Repr
-
-pub(all) enum UnifiedDiffLineKind {
- Context
- Deletion
- Addition
-} derive(Eq, @debug.Debug)
-pub fn UnifiedDiffLineKind::equal(Self, Self) -> Bool
-pub fn UnifiedDiffLineKind::not_equal(Self, Self) -> Bool
-pub fn UnifiedDiffLineKind::to_repr(Self) -> @debug.Repr
-
-// Type aliases
-
-// Traits
diff --git a/editor/viewer/common/unified_diff/unified_diff.mbt b/editor/viewer/common/unified_diff/unified_diff.mbt
deleted file mode 100644
index 471fbc812..000000000
--- a/editor/viewer/common/unified_diff/unified_diff.mbt
+++ /dev/null
@@ -1,144 +0,0 @@
-///|
-fn UnifiedDiffAlgorithm::compute_changes(
- self : UnifiedDiffAlgorithm,
- original_lines : Array[String],
- modified_lines : Array[String],
- options : UnifiedDiffAlgorithmOptions,
-) -> Array[UnifiedDiffChange] {
- (self.compute_changes_callback)(original_lines, modified_lines, options)
-}
-
-///|
-priv struct HunkWindow {
- original_start : Int
- mut original_end_exclusive : Int
- modified_start : Int
- mut modified_end_exclusive : Int
- changes : Array[UnifiedDiffChange]
-}
-
-///|
-fn hunk_windows(
- changes : Array[UnifiedDiffChange],
- original_line_count : Int,
- modified_line_count : Int,
- context_lines : Int,
-) -> Array[HunkWindow] {
- let windows : Array[HunkWindow] = []
- for change in changes {
- let original_limit = original_line_count + 1
- let modified_limit = modified_line_count + 1
- let original_start = change.original_start_line_number -
- context_lines.min(change.original_start_line_number - 1)
- let original_end_exclusive = change.original_end_line_number_exclusive +
- context_lines.min(
- original_limit - change.original_end_line_number_exclusive,
- )
- let modified_start = change.modified_start_line_number -
- context_lines.min(change.modified_start_line_number - 1)
- let modified_end_exclusive = change.modified_end_line_number_exclusive +
- context_lines.min(
- modified_limit - change.modified_end_line_number_exclusive,
- )
- match windows.last() {
- Some(previous) if original_start <= previous.original_end_exclusive &&
- modified_start <= previous.modified_end_exclusive => {
- previous.original_end_exclusive = previous.original_end_exclusive.max(
- original_end_exclusive,
- )
- previous.modified_end_exclusive = previous.modified_end_exclusive.max(
- modified_end_exclusive,
- )
- previous.changes.push(change)
- }
- _ =>
- windows.push({
- original_start,
- original_end_exclusive,
- modified_start,
- modified_end_exclusive,
- changes: [change],
- })
- }
- }
- windows
-}
-
-///|
-fn HunkWindow::to_hunk(
- self : HunkWindow,
- original_lines : Array[String],
- modified_lines : Array[String],
-) -> UnifiedDiffHunk {
- let lines : Array[UnifiedDiffLine] = []
- let mut original_line = self.original_start
- let mut modified_line = self.modified_start
- for change in self.changes {
- while original_line < change.original_start_line_number &&
- modified_line < change.modified_start_line_number {
- lines.push({
- kind: Context,
- original_line_number: Some(original_line),
- modified_line_number: Some(modified_line),
- text: modified_lines[modified_line - 1],
- })
- original_line += 1
- modified_line += 1
- }
- while original_line < change.original_end_line_number_exclusive {
- lines.push({
- kind: Deletion,
- original_line_number: Some(original_line),
- modified_line_number: None,
- text: original_lines[original_line - 1],
- })
- original_line += 1
- }
- while modified_line < change.modified_end_line_number_exclusive {
- lines.push({
- kind: Addition,
- original_line_number: None,
- modified_line_number: Some(modified_line),
- text: modified_lines[modified_line - 1],
- })
- modified_line += 1
- }
- }
- while original_line < self.original_end_exclusive &&
- modified_line < self.modified_end_exclusive {
- lines.push({
- kind: Context,
- original_line_number: Some(original_line),
- modified_line_number: Some(modified_line),
- text: modified_lines[modified_line - 1],
- })
- original_line += 1
- modified_line += 1
- }
- let original_line_count = self.original_end_exclusive - self.original_start
- let modified_line_count = self.modified_end_exclusive - self.modified_start
- {
- original_start_line_number: if original_line_count == 0 {
- self.original_start - 1
- } else {
- self.original_start
- },
- original_line_count,
- modified_start_line_number: if modified_line_count == 0 {
- self.modified_start - 1
- } else {
- self.modified_start
- },
- modified_line_count,
- lines,
- }
-}
-
-///|
-fn canonical_diff_lines(lines : Array[String]) -> Array[String] {
- if lines.length() == 1 && lines[0].is_empty() {
- []
- } else {
- lines
- }
-}
diff --git a/editor/viewer/common/unified_diff/unified_diff_wbtest.mbt b/editor/viewer/common/unified_diff/unified_diff_wbtest.mbt
deleted file mode 100644
index 75b5a0877..000000000
--- a/editor/viewer/common/unified_diff/unified_diff_wbtest.mbt
+++ /dev/null
@@ -1,156 +0,0 @@
-///|
-test "unchanged input has no hunks" {
- assert_eq(compute_unified_diff(["same"], ["same"]), [])
-}
-
-///|
-test "empty document representations are canonicalized" {
- assert_eq(compute_unified_diff([], [""]), [])
- assert_eq(compute_unified_diff([""], []), [])
- assert_eq(compute_unified_diff([""], [""]), [])
-}
-
-///|
-test "the line-matching algorithm is replaceable" {
- let saw_ignore_trim_whitespace = Ref(false)
- let algorithm = UnifiedDiffAlgorithm(compute_changes=(_, _, options) => {
- saw_ignore_trim_whitespace.val = options.ignore_trim_whitespace
- [
- {
- original_start_line_number: 1,
- original_end_line_number_exclusive: 2,
- modified_start_line_number: 1,
- modified_end_line_number_exclusive: 2,
- },
- ]
- })
- let hunks = compute_unified_diff(
- ["old"],
- ["new"],
- context_lines=0,
- ignore_trim_whitespace=true,
- algorithm~,
- )
- assert_true(saw_ignore_trim_whitespace.val)
- assert_eq(hunks.length(), 1)
- assert_eq(hunks[0].lines.map(line => line.kind), [Deletion, Addition])
-}
-
-///|
-test "a replacement carries context and both line-number domains" {
- assert_eq(
- compute_unified_diff(
- ["before", "old", "after"],
- ["before", "new", "after"],
- context_lines=1,
- ),
- [
- {
- original_start_line_number: 1,
- original_line_count: 3,
- modified_start_line_number: 1,
- modified_line_count: 3,
- lines: [
- {
- kind: Context,
- original_line_number: Some(1),
- modified_line_number: Some(1),
- text: "before",
- },
- {
- kind: Deletion,
- original_line_number: Some(2),
- modified_line_number: None,
- text: "old",
- },
- {
- kind: Addition,
- original_line_number: None,
- modified_line_number: Some(2),
- text: "new",
- },
- {
- kind: Context,
- original_line_number: Some(3),
- modified_line_number: Some(3),
- text: "after",
- },
- ],
- },
- ],
- )
-}
-
-///|
-test "nearby changes merge and distant changes remain separate" {
- let original = ["0", "old-1", "2", "3", "4", "old-2", "6"]
- let modified = ["0", "new-1", "2", "3", "4", "new-2", "6"]
- assert_eq(
- compute_unified_diff(original, modified, context_lines=1).length(),
- 2,
- )
- assert_eq(
- compute_unified_diff(original, modified, context_lines=2).length(),
- 1,
- )
-}
-
-///|
-test "context is bounded before arithmetic can overflow" {
- let hunks = compute_unified_diff(["old"], ["new"], context_lines=0x7FFFFFFF)
- assert_eq(hunks.length(), 1)
- assert_eq(hunks[0].original_line_count, 1)
- assert_eq(hunks[0].modified_line_count, 1)
-}
-
-///|
-test "a new file uses the zero anchor on its empty original side" {
- assert_eq(compute_unified_diff([""], ["one", "two"]), [
- {
- original_start_line_number: 0,
- original_line_count: 0,
- modified_start_line_number: 1,
- modified_line_count: 2,
- lines: [
- {
- kind: Addition,
- original_line_number: None,
- modified_line_number: Some(1),
- text: "one",
- },
- {
- kind: Addition,
- original_line_number: None,
- modified_line_number: Some(2),
- text: "two",
- },
- ],
- },
- ])
-}
-
-///|
-test "a deleted file uses the zero anchor on its empty modified side" {
- assert_eq(compute_unified_diff(["one", "two"], []), [
- {
- original_start_line_number: 1,
- original_line_count: 2,
- modified_start_line_number: 0,
- modified_line_count: 0,
- lines: [
- {
- kind: Deletion,
- original_line_number: Some(1),
- modified_line_number: None,
- text: "one",
- },
- {
- kind: Deletion,
- original_line_number: Some(2),
- modified_line_number: None,
- text: "two",
- },
- ],
- },
- ])
-}
diff --git a/editor/viewer/common/view_layout/lines_layout.mbt b/editor/viewer/common/view_layout/lines_layout.mbt
index 01bd171b0..38a5dd3fd 100644
--- a/editor/viewer/common/view_layout/lines_layout.mbt
+++ b/editor/viewer/common/view_layout/lines_layout.mbt
@@ -21,7 +21,7 @@ pub struct EditorWhitespace {
mut after_line_number : Int
ordinal : Int
mut height : Int
- min_width : Int
+ mut min_width : Int
mut prefix_sum : Double
} derive(Eq, Debug)
@@ -66,6 +66,7 @@ priv struct PendingChange {
id : String
new_after_line_number : Int
new_height : Int
+ new_min_width : Int?
}
///|
@@ -154,6 +155,7 @@ fn javascript_to_int32(value : Double) -> Int {
struct WhitespaceChangeAccessor {
layout : LinesLayout
mut had_a_change : Bool
+ mut min_width_update_requested : Bool
}
///|
@@ -185,10 +187,19 @@ pub fn WhitespaceChangeAccessor::change_one_whitespace(
id : String,
new_after_line_number : Int,
new_height : Double,
+ new_min_width? : Double,
) -> Unit {
self.had_a_change = true
+ if new_min_width is Some(_) {
+ self.min_width_update_requested = true
+ }
let new_height = javascript_to_int32(new_height)
- self.layout.pending_changes.change({ id, new_after_line_number, new_height })
+ self.layout.pending_changes.change({
+ id,
+ new_after_line_number,
+ new_height,
+ new_min_width: new_min_width.map(javascript_to_int32),
+ })
}
///|
@@ -302,12 +313,25 @@ pub fn LinesLayout::change_whitespace(
self : LinesLayout,
callback : (WhitespaceChangeAccessor) -> Unit raise?,
) -> Bool raise? {
- let accessor = { layout: self, had_a_change: false }
+ let (had_a_change, _) = self.change_whitespace_with_width(callback)
+ had_a_change
+}
+
+///|
+fn LinesLayout::change_whitespace_with_width(
+ self : LinesLayout,
+ callback : (WhitespaceChangeAccessor) -> Unit raise?,
+) -> (Bool, Bool) raise? {
+ let accessor = {
+ layout: self,
+ had_a_change: false,
+ min_width_update_requested: false,
+ }
// Monaco commits in `finally`: queued work survives a callback failure, and
// the queues are cleared before that failure continues to the caller.
defer self.pending_changes.commit(self)
callback(accessor)
- accessor.had_a_change
+ (accessor.had_a_change, accessor.min_width_update_requested)
}
///|
@@ -317,7 +341,9 @@ fn LinesLayout::commit_pending_changes(
changes : Array[PendingChange],
removes : Array[PendingRemove],
) -> Unit {
- if inserts.length() > 0 || removes.length() > 0 {
+ if inserts.length() > 0 ||
+ removes.length() > 0 ||
+ changes.any(change => change.new_min_width is Some(_)) {
self.min_width = -1
}
if inserts.length() + changes.length() + removes.length() <= 1 {
@@ -330,6 +356,7 @@ fn LinesLayout::commit_pending_changes(
change.id,
change.new_after_line_number,
change.new_height,
+ change.new_min_width,
)
}
for remove in removes {
@@ -363,6 +390,10 @@ fn LinesLayout::commit_pending_changes(
Some(change) => {
whitespace.after_line_number = change.new_after_line_number
whitespace.height = change.new_height
+ match change.new_min_width {
+ Some(min_width) => whitespace.min_width = min_width
+ None => ()
+ }
}
None => ()
}
@@ -439,6 +470,7 @@ fn LinesLayout::change_one_whitespace(
id : String,
new_after_line_number : Int,
new_height : Int,
+ new_min_width : Int?,
) -> Unit {
let index = self.find_whitespace_index(id)
if index == -1 {
@@ -448,6 +480,14 @@ fn LinesLayout::change_one_whitespace(
self.arr[index].height = new_height
self.prefix_sum_valid_index = self.prefix_sum_valid_index.min(index - 1)
}
+ match new_min_width {
+ Some(min_width) =>
+ if self.arr[index].min_width != min_width {
+ self.arr[index].min_width = min_width
+ self.min_width = -1
+ }
+ None => ()
+ }
if self.arr[index].after_line_number != new_after_line_number {
// `afterLineNumber` changed: removing then re-inserting may reorder it.
let whitespace = self.arr[index]
diff --git a/editor/viewer/common/view_layout/pkg.generated.mbti b/editor/viewer/common/view_layout/pkg.generated.mbti
index 3ed48108a..7de2acc30 100644
--- a/editor/viewer/common/view_layout/pkg.generated.mbti
+++ b/editor/viewer/common/view_layout/pkg.generated.mbti
@@ -128,7 +128,7 @@ pub struct EditorWhitespace {
mut after_line_number : Int
ordinal : Int
mut height : Int
- min_width : Int
+ mut min_width : Int
mut prefix_sum : Double
} derive(Eq, @debug.Debug)
pub fn EditorWhitespace::EditorWhitespace(String, Int, Int, Int, Int) -> Self
@@ -624,7 +624,7 @@ pub fn ViewportData::not_equal(Self, Self) -> Bool
pub fn ViewportData::to_repr(Self) -> @debug.Repr
type WhitespaceChangeAccessor
-pub fn WhitespaceChangeAccessor::change_one_whitespace(Self, String, Int, Double) -> Unit
+pub fn WhitespaceChangeAccessor::change_one_whitespace(Self, String, Int, Double, new_min_width? : Double) -> Unit
pub fn WhitespaceChangeAccessor::insert_whitespace(Self, Int, Int, Double, Double) -> String
pub fn WhitespaceChangeAccessor::remove_whitespace(Self, String) -> Unit
diff --git a/editor/viewer/common/view_layout/view_layout.mbt b/editor/viewer/common/view_layout/view_layout.mbt
index 457058750..a68eb9497 100644
--- a/editor/viewer/common/view_layout/view_layout.mbt
+++ b/editor/viewer/common/view_layout/view_layout.mbt
@@ -118,9 +118,17 @@ pub fn ViewLayout::change_whitespace(
self : ViewLayout,
callback : (WhitespaceChangeAccessor) -> Unit raise?,
) -> Bool raise? {
- let had_a_change = self.lines.change_whitespace(callback)
+ let (had_a_change, min_width_update_requested) = self.lines.change_whitespace_with_width(
+ callback,
+ )
if had_a_change {
- self.on_height_maybe_changed() |> ignore
+ if min_width_update_requested {
+ // A retained ViewZone can raise its discovered minimum width without
+ // changing vertical geometry. Recompute both axes in the same transaction.
+ self.update_content_width() |> ignore
+ } else {
+ self.on_height_maybe_changed() |> ignore
+ }
}
had_a_change
}
diff --git a/editor/viewer/common/view_layout/view_zones_layout_wbtest.mbt b/editor/viewer/common/view_layout/view_zones_layout_wbtest.mbt
index f9d4a1eb0..7e1156a84 100644
--- a/editor/viewer/common/view_layout/view_zones_layout_wbtest.mbt
+++ b/editor/viewer/common/view_layout/view_zones_layout_wbtest.mbt
@@ -156,6 +156,30 @@ test "ViewLayout incremental whitespace facade updates height and preserves IDs"
assert_eq(layout.get_content_height(), 50.0)
}
+///|
+test "retained whitespace min width updates the horizontal extent transactionally" {
+ let layout = ViewLayout(line_height=10, line_count=3)
+ layout.set_viewport_size(width=100.0, height=100.0) |> ignore
+ layout.set_max_line_width(20.0) |> ignore
+ let id = Ref("")
+ layout.change_whitespace(accessor => {
+ id.val = accessor.insert_whitespace(1, 0, 10.0, 0.0)
+ })
+ |> ignore
+ assert_eq(layout.get_content_width(), 34.0)
+ layout.change_whitespace(accessor => {
+ accessor.change_one_whitespace(id.val, 1, 10.0, new_min_width=250.0)
+ })
+ |> ignore
+ assert_eq(layout.get_whitespaces()[0].min_width, 250)
+ assert_eq(layout.get_content_width(), 250.0)
+ layout.change_whitespace(accessor => {
+ accessor.change_one_whitespace(id.val, 1, 10.0, new_min_width=0.0)
+ })
+ |> ignore
+ assert_eq(layout.get_content_width(), 34.0)
+}
+
///|
test "ViewLayout exposes ID whitespace viewport and explicit scrollTop data" {
let layout = ViewLayout(line_height=10, line_count=20)
diff --git a/editor/viewer/definition_peek_host.mbt b/editor/viewer/definition_peek_host.mbt
deleted file mode 100644
index ab1bf7f9d..000000000
--- a/editor/viewer/definition_peek_host.mbt
+++ /dev/null
@@ -1,1923 +0,0 @@
-///|
-priv struct DefinitionPeekRequestStamp {
- generation : Int
- model_identity : Int
- model_generation : Int
- model_version_id : Int
-}
-
-///|
-/// Preview resources detached atomically from the shared References state before any
-/// cancellation or disposal callback can re-enter the Viewer.
-priv struct DetachedReferencesPeekPreview {
- viewer : Viewer?
- reference : @navigation_api.TextModelReference?
- decorations : EditorDecorationsCollection?
- layout_frame : @base_common.Disposable?
- resize_observer : @base_common.Disposable?
- restore_focus : Bool
-}
-
-///|
-/// Local selected-preview transaction. None of these owners enter shared
-/// contribution state until every synchronous child/editor callback boundary
-/// has completed and the exact root session is still current.
-priv struct ProvisionalReferencesPeekPreview {
- viewer : Viewer
- mut reference : @navigation_api.TextModelReference?
- mut decorations : EditorDecorationsCollection?
- mut layout_frame : @base_common.Disposable?
- mut resize_observer : @base_common.Disposable?
-}
-
-///|
-fn ProvisionalReferencesPeekPreview::dispose(
- self : ProvisionalReferencesPeekPreview,
-) -> Unit {
- match self.resize_observer {
- Some(observer) => observer.dispose()
- None => ()
- }
- match self.layout_frame {
- Some(frame) => frame.dispose()
- None => ()
- }
- self.resize_observer = None
- self.layout_frame = None
- self.viewer.set_model(None)
- self.viewer.dispose()
- match self.reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- self.reference = None
- match self.decorations {
- Some(decorations) => decorations.clear()
- None => ()
- }
- self.decorations = None
-}
-
-///|
-fn ProvisionalReferencesPeekPreview::publish(
- self : ProvisionalReferencesPeekPreview,
- state : ReferencesContributionState,
-) -> Unit {
- state.preview_viewer = Some(self.viewer)
- state.preview_reference = self.reference
- state.preview_decorations = self.decorations
- state.preview_layout_frame = self.layout_frame
- state.preview_resize_observer = self.resize_observer
- self.reference = None
- self.decorations = None
- self.layout_frame = None
- self.resize_observer = None
-}
-
-///|
-fn DetachedReferencesPeekPreview::dispose_viewer(
- self : DetachedReferencesPeekPreview,
-) -> Unit {
- match self.resize_observer {
- Some(observer) => observer.dispose()
- None => ()
- }
- match self.layout_frame {
- Some(frame) => frame.dispose()
- None => ()
- }
- match self.viewer {
- Some(viewer) => {
- viewer.set_model(None)
- viewer.dispose()
- }
- None => ()
- }
-}
-
-///|
-fn DetachedReferencesPeekPreview::dispose_reference(
- self : DetachedReferencesPeekPreview,
-) -> Unit {
- match self.reference {
- Some(reference) => reference.dispose()
- None => ()
- }
-}
-
-///|
-fn DetachedReferencesPeekPreview::dispose_decorations(
- self : DetachedReferencesPeekPreview,
-) -> Unit {
- match self.decorations {
- Some(decorations) => decorations.clear()
- None => ()
- }
-}
-
-///|
-fn DetachedReferencesPeekPreview::dispose(
- self : DetachedReferencesPeekPreview,
-) -> Unit {
- self.dispose_viewer()
- self.dispose_reference()
- self.dispose_decorations()
-}
-
-///|
-/// Group resources detached as one value before any cancellation/release
-/// callback runs. Sources and leases retain their group order only to keep
-/// teardown deterministic.
-priv struct DetachedReferencesGroupPreviews {
- sources : Array[@language.CancellationTokenSource]
- references : Array[@navigation_api.TextModelReference]
-}
-
-///|
-fn DetachedReferencesGroupPreviews::cancel_sources(
- self : DetachedReferencesGroupPreviews,
-) -> Unit {
- for source in self.sources {
- source.dispose(cancel=true)
- }
-}
-
-///|
-fn DetachedReferencesGroupPreviews::dispose_references(
- self : DetachedReferencesGroupPreviews,
-) -> Unit {
- for reference in self.references {
- reference.dispose()
- }
-}
-
-///|
-fn ReferencesContributionState::retire_preview_source(
- self : ReferencesContributionState,
- source : @language.CancellationTokenSource,
- cancel~ : Bool,
-) -> Unit {
- match self.preview_source {
- Some(current) if physical_equal(current, source) => {
- self.preview_source = None
- source.dispose(cancel~)
- }
- _ => ()
- }
-}
-
-///|
-fn Viewer::definition_peek_available(self : Viewer) -> Bool {
- self.references_peek_available() &&
- self.definition_action_available() &&
- self.get_model() is Some(_)
-}
-
-///|
-fn Viewer::references_action_available(self : Viewer) -> Bool {
- guard self.references_peek_available() else { return false }
- match self.get_model() {
- Some(model) => self.services.languages.has_references_provider(model)
- None => false
- }
-}
-
-///|
-fn Viewer::references_peek_available(self : Viewer) -> Bool {
- !self.disposed &&
- self.role == OuterViewer &&
- self.get_model() is Some(_) &&
- (
- self.current_code_browser_data() is Some(_) ||
- self.current_markdown_document() is Some(_)
- )
-}
-
-///|
-fn Viewer::definition_peek_contains_focus(self : Viewer) -> Bool {
- match self.references_contribution() {
- Some(state) =>
- match state.widget {
- Some(widget) =>
- @base_browser.element_contains_active_element(widget.get_dom_node())
- None => false
- }
- None => false
- }
-}
-
-///|
-fn Viewer::start_peek_definition(self : Viewer) -> Unit {
- guard self.definition_peek_available() else { return }
- guard self.definition_contribution() is Some(definition) else { return }
- guard self.references_contribution() is Some(references) else { return }
- guard self.get_model() is Some(model) else { return }
- guard self.definition_command_position() is Some(position) else { return }
- let position = model.validate_position(position)
- let session_key = @references.ReferencesSessionKey::from_model(
- model,
- self.model_slot.generation,
- position,
- )
- if self.references_peek_session_is_open(session_key, Definitions) {
- self.close_references_peek(restore_focus=true)
- return
- }
- guard self.begin_definition_peek(position, session_key) is Some(generation) else {
- return
- }
- let source = @language.CancellationTokenSource()
- definition.peek_source = Some(source)
- references.phase = PeekLoadingDefinitions(generation)
- let stamp : DefinitionPeekRequestStamp = {
- generation,
- model_identity: model.identity(),
- model_generation: self.model_slot.generation,
- model_version_id: model.get_version_id(),
- }
- let offset = model.get_offset_at(position)
- let task = definition.launch_async_task
- task(async fn() noraise {
- let locations = self.services.languages.definition_at(
- model,
- offset,
- token=source.token(),
- run_tasks=run_async_tasks_concurrently,
- )
- if !self.definition_peek_request_is_current(stamp, source) {
- definition.retire_peek_source(source, cancel=true)
- return
- }
- source.dispose()
- definition.peek_source = None
- self.install_definition_peek_locations(
- generation,
- position,
- @definition.normalize_definition_locations(locations),
- )
- })
-}
-
-///|
-/// Populates Peek from locations already resolved by the ordinary Definition
-/// action. This preserves the one-provider-query contract for the default
-/// multiple-definition Peek policy.
-fn Viewer::open_definition_peek_with_locations(
- self : Viewer,
- position : @base_common.Position,
- locations : Array[@language.Location],
-) -> Unit {
- guard self.definition_peek_available() else { return }
- guard self.get_model() is Some(model) else { return }
- let position = model.validate_position(position)
- let session_key = @references.ReferencesSessionKey::from_model(
- model,
- self.model_slot.generation,
- position,
- )
- if self.references_peek_session_is_open(session_key, Definitions) {
- self.close_references_peek(restore_focus=true)
- return
- }
- guard self.begin_definition_peek(position, session_key) is Some(generation) else {
- return
- }
- self.install_definition_peek_locations(generation, position, locations)
-}
-
-///|
-/// Starts the provider-backed References action and immediately installs the
-/// shared loading shell. Shift+F12 and the context-menu command both enter
-/// here; the public `show_references` seam remains provider-free.
-fn Viewer::start_peek_references(self : Viewer) -> Unit {
- guard self.references_action_available() else { return }
- guard self.references_contribution() is Some(references) else { return }
- guard self.get_model() is Some(model) else { return }
- guard self.definition_command_position() is Some(position) else { return }
- let position = model.validate_position(position)
- let session_key = @references.ReferencesSessionKey::from_model(
- model,
- self.model_slot.generation,
- position,
- )
- if self.references_peek_session_is_open(session_key, References) {
- self.close_references_peek(restore_focus=true)
- return
- }
- let expected_closed_generation = references.generation + 1
- self.close_references_peek(restore_focus=false)
- guard self.references_peek_closed_generation_is_current(
- expected_closed_generation,
- ) else {
- return
- }
- self.prepare_definition_intent_for_peek()
- guard self.references_peek_closed_generation_is_current(
- expected_closed_generation,
- ) else {
- return
- }
- guard self.begin_references_peek_session(
- position,
- session_key,
- References,
- expected_closed_generation,
- )
- is Some(generation) else {
- return
- }
- let source = @language.CancellationTokenSource()
- references.query_source = Some(source)
- references.phase = PeekLoadingReferences(generation)
- let stamp : ReferencesQueryStamp = {
- generation,
- model_identity: model.identity(),
- model_generation: self.model_slot.generation,
- model_version_id: model.get_version_id(),
- }
- let offset = model.get_offset_at(position)
- let task = references.launch_async_task
- task(async fn() noraise {
- let locations = self.services.languages.references_at(
- model,
- offset,
- token=source.token(),
- run_tasks=run_async_tasks_concurrently,
- )
- if !self.references_query_is_current(stamp, source) {
- references.retire_query_source(source, cancel=true)
- return
- }
- source.dispose()
- references.query_source = None
- self.install_references_peek_locations(generation, position, locations)
- })
-}
-
-///|
-/// Displays an already-computed reference set without invoking a language
-/// provider. The caller's array is copied before the shared model normalizes
-/// it, so the Peek never retains or mutates caller-owned collection state.
-pub fn Viewer::show_references(
- self : Viewer,
- anchor : @base_common.Position,
- locations : Array[@language.Location],
-) -> Unit {
- guard self.references_peek_available() else { return }
- guard self.get_model() is Some(model) else { return }
- let anchor = model.validate_position(anchor)
- let session_key = @references.ReferencesSessionKey::from_model(
- model,
- self.model_slot.generation,
- anchor,
- )
- if self.references_peek_session_is_open(session_key, References) {
- self.close_references_peek(restore_focus=true)
- return
- }
- guard self.references_contribution() is Some(references) else { return }
- let copied_locations = locations.copy()
- let expected_closed_generation = references.generation + 1
- self.close_references_peek(restore_focus=false)
- guard self.references_peek_closed_generation_is_current(
- expected_closed_generation,
- ) else {
- return
- }
- self.prepare_definition_intent_for_peek()
- guard self.references_peek_closed_generation_is_current(
- expected_closed_generation,
- ) else {
- return
- }
- guard self.begin_references_peek_session(
- anchor,
- session_key,
- References,
- expected_closed_generation,
- )
- is Some(generation) else {
- return
- }
- self.install_references_peek_locations(generation, anchor, copied_locations)
-}
-
-///|
-fn Viewer::references_peek_session_is_open(
- self : Viewer,
- session_key : @references.ReferencesSessionKey,
- mode : @references.ReferencesPeekMode,
-) -> Bool {
- match self.references_contribution() {
- Some(state) =>
- state.session_key == Some(session_key) &&
- state.mode == Some(mode) &&
- state.widget is Some(_)
- None => false
- }
-}
-
-///|
-fn Viewer::references_peek_closed_generation_is_current(
- self : Viewer,
- generation : Int,
-) -> Bool {
- guard !self.disposed else { return false }
- match self.references_contribution() {
- Some(state) =>
- state.generation == generation &&
- state.phase == PeekClosed &&
- state.query_source is None &&
- state.mode is None &&
- state.session_key is None &&
- state.model is None &&
- state.widget is None
- None => false
- }
-}
-
-///|
-/// A direct locations Peek is a newer navigation intent than pending
-/// Definition actions, modifier-link resolution, or opener feedback. Markdown
-/// retains its pointer anchor just like Peek Definition; Code resets it.
-fn Viewer::prepare_definition_intent_for_peek(self : Viewer) -> Unit {
- self.clear_definition_message()
- match self.definition_contribution() {
- Some(definition) => {
- definition.invalidate_open_feedback()
- definition.cancel_action()
- if self.current_markdown_document() is Some(_) {
- definition.cancel_link_gesture()
- } else {
- definition.reset_link_gesture()
- }
- }
- None => ()
- }
-}
-
-///|
-fn Viewer::begin_definition_peek(
- self : Viewer,
- position : @base_common.Position,
- session_key : @references.ReferencesSessionKey,
-) -> Int? {
- guard self.definition_contribution() is Some(_) else { return None }
- guard self.references_contribution() is Some(references) else { return None }
- let expected_closed_generation = references.generation + 1
- self.close_references_peek(restore_focus=false)
- guard self.references_peek_closed_generation_is_current(
- expected_closed_generation,
- ) else {
- return None
- }
- self.prepare_definition_intent_for_peek()
- guard self.references_peek_closed_generation_is_current(
- expected_closed_generation,
- ) else {
- return None
- }
- let attempt_generation = expected_closed_generation + 1
- match
- self.begin_references_peek_session(
- position,
- session_key,
- Definitions,
- expected_closed_generation,
- ) {
- Some(generation) => Some(generation)
- None => {
- // A ViewZone listener can replace the failed attempt synchronously.
- // Definition feedback belongs only to the still-closed attempt, never to
- // a newer Peek installed before shell construction returned.
- if self.references_peek_closed_generation_is_current(attempt_generation) {
- self.show_definition_message("No preview available", position~)
- }
- None
- }
- }
-}
-
-///|
-/// Starts the shared shell after the caller has retired any previous session
-/// and completed mode-specific preparation.
-fn Viewer::begin_references_peek_session(
- self : Viewer,
- position : @base_common.Position,
- session_key : @references.ReferencesSessionKey,
- mode : @references.ReferencesPeekMode,
- expected_closed_generation : Int,
-) -> Int? {
- guard self.references_peek_closed_generation_is_current(
- expected_closed_generation,
- ) else {
- return None
- }
- guard self.references_contribution() is Some(references) else { return None }
- references.generation += 1
- let generation = references.generation
- references.mode = Some(mode)
- references.session_key = Some(session_key)
- guard self.open_references_peek_shell(position, generation, mode) else {
- // Shell construction crosses synchronous browser-event boundaries. A
- // listener may already have installed a newer session; only retire this
- // failed attempt while its generation is still the published owner.
- if references.generation == generation {
- references.mode = None
- references.session_key = None
- }
- return None
- }
- Some(generation)
-}
-
-///|
-fn Viewer::install_definition_peek_locations(
- self : Viewer,
- generation : Int,
- source_position : @base_common.Position,
- locations : Array[@language.Location],
-) -> Unit {
- self.install_references_peek_locations(generation, source_position, locations)
-}
-
-///|
-fn Viewer::install_references_peek_locations(
- self : Viewer,
- generation : Int,
- source_position : @base_common.Position,
- locations : Array[@language.Location],
-) -> Unit {
- guard self.references_peek_session_is_current(generation) else { return }
- guard self.references_contribution() is Some(state) else { return }
- guard self.get_model() is Some(source_model) else { return }
- let result_model = @references.ReferencesModel(locations)
- state.model = Some(result_model)
- // `set_results` synchronously reports the initially expanded group. Install
- // every owner first so that callback always sees a complete session.
- let group_preview_owners : Array[ReferencesGroupPreviewOwner] = []
- for group in result_model.groups {
- group_preview_owners.push(ReferencesGroupPreviewOwner(group.group_index))
- }
- state.group_preview_owners = group_preview_owners
- if result_model.is_empty() {
- if state.mode == Some(Definitions) {
- // Definition retains its command-specific no-result feedback. The
- // precomputed References entry instead keeps this shell open.
- self.close_references_peek(restore_focus=true)
- self.show_definition_message(
- self.definition_no_result_message(source_position),
- position=source_position,
- )
- } else {
- match state.widget {
- Some(widget) => widget.show_empty()
- None => ()
- }
- state.phase = PeekShown(generation, state.preview_generation)
- }
- return
- }
- guard result_model.nearest_reference(source_model.uri, source_position)
- is Some(selected) else {
- return
- }
- let selected_index = selected.flat_index
- state.selected_index = selected_index
- match state.widget {
- Some(widget) => {
- widget.set_results(result_model, Some(selected))
- widget.show_preview_loading()
- }
- None => return
- }
- self.load_references_peek_preview(generation, selected_index)
-}
-
-///|
-fn Viewer::open_references_peek_shell(
- self : Viewer,
- position : @base_common.Position,
- generation : Int,
- mode : @references.ReferencesPeekMode,
-) -> Bool {
- guard self.references_contribution() is Some(state) else { return false }
- let widget = @references_browser.ReferencesPeekWidget(mode, {
- on_close: () => {
- if self.references_peek_session_is_current(generation) {
- self.close_references_peek(restore_focus=true)
- }
- },
- on_select: item => {
- if self.references_peek_session_is_current(generation) {
- self.select_references_peek_item(item)
- }
- },
- on_confirm: item => {
- if self.references_peek_session_is_current(generation) &&
- self.references_peek_item_is_current(item) {
- self.confirm_references_peek_result(item.flat_index)
- }
- },
- on_open_side: item => {
- if self.references_peek_session_is_current(generation) {
- self.confirm_references_peek_item(item, Side)
- }
- },
- on_next: () => {
- if self.references_peek_session_is_current(generation) {
- self.move_references_peek_result(1)
- }
- },
- on_previous: () => {
- if self.references_peek_session_is_current(generation) {
- self.move_references_peek_result(-1)
- }
- },
- on_expand_group: group => {
- if self.references_peek_session_is_current(generation) {
- self.load_references_peek_group(group)
- }
- },
- })
- // Publish the callback owner before the first synchronous browser mutation.
- // `change_view_zones` emits publicly before returning, so a listener may
- // close this provisional shell and install another one. The mount facts stay
- // local until each reentrant boundary has confirmed this exact owner.
- state.widget = Some(widget)
- let mounted_view : Ref[@view.View?] = Ref(None)
- let mounted_zone_id : Ref[String?] = Ref(None)
- let mounted_overlay : Ref[@editor_browser.OverlayWidget?] = Ref(None)
- let markdown_mounted = Ref(false)
- // Every abandon path below unmounts the same shell, and each reads the mount
- // facts as they stand at that point. Binding the call once keeps the reads
- // late while leaving one place to extend the resource set.
- let retire = () => {
- self.retire_provisional_references_peek_shell(
- state,
- generation,
- widget,
- mounted_view.val,
- mounted_zone_id.val,
- mounted_overlay.val,
- markdown_mounted.val,
- )
- }
- match self.current_code_browser_data() {
- Some(browser) => {
- let zone_dom_node = @rdom.document().create_element("div")
- zone_dom_node.set_attribute(
- "class", "moonbit-viewer-references-peek-zone",
- )
- let widget_dom_node = widget.get_dom_node()
- let layout = self.get_layout_info()
- @base_browser.set_style_property(widget_dom_node, "position", "absolute")
- @base_browser.set_style_property(widget_dom_node, "top", "-1000000px")
- @base_browser.set_style_property(widget_dom_node, "left", "0")
- @base_browser.set_style_property(
- widget_dom_node,
- "right",
- "\{layout.vertical_scrollbar_width.to_string()}px",
- )
- @base_browser.set_style_property(widget_dom_node, "width", "auto")
- let zone = @editor_browser.ViewZone(
- position.line_number,
- zone_dom_node,
- after_column=position.column,
- suppress_mouse_down=true,
- height_in_lines=self.references_peek_height_in_lines(),
- on_dom_node_top=top => {
- @base_browser.set_style_property(
- widget_dom_node,
- "top",
- "\{top.to_string()}px",
- )
- },
- on_computed_height=height => {
- @base_browser.set_style_property(
- widget_dom_node,
- "height",
- "\{height.to_string()}px",
- )
- },
- )
- let added_id = Ref("")
- self.change_view_zones(accessor => added_id.val = accessor.add_zone(zone))
- if added_id.val == "" {
- retire()
- return false
- }
- mounted_view.val = Some(browser.view)
- mounted_zone_id.val = Some(added_id.val)
- if !self.references_peek_shell_owner_is_current(generation, widget) {
- retire()
- return false
- }
- state.zone_id = Some(added_id.val)
- let overlay = overlay_widget(
- "moonbit.viewer.referencesPeek.\{self.get_id()}",
- widget_dom_node,
- )
- self.add_overlay_widget(overlay)
- mounted_overlay.val = Some(overlay)
- if !self.references_peek_shell_owner_is_current(generation, widget) {
- retire()
- return false
- }
- state.overlay_widget = Some(overlay)
- self.reveal_references_peek_zone(position)
- if !self.references_peek_session_is_current(generation) {
- retire()
- return false
- }
- }
- None =>
- match self.current_markdown_document() {
- Some(markdown) => {
- widget
- .get_dom_node()
- .set_attribute(
- "class", "moonbit-viewer-references-peek moonbit-viewer-references-peek-overlay",
- )
- markdown.overlay_mount().append_child(widget.get_dom_node().as_node())
- markdown_mounted.val = true
- if !self.references_peek_shell_owner_is_current(generation, widget) {
- retire()
- return false
- }
- state.markdown_projection_generation = Some(
- markdown.projection_generation(),
- )
- }
- None => {
- retire()
- return false
- }
- }
- }
- guard self.references_peek_session_is_current(generation) else {
- retire()
- return false
- }
- state.focus_frame = Some(
- @base_browser.schedule_at_next_animation_frame(
- () => {
- if state.generation == generation {
- state.focus_frame = None
- }
- if self.references_peek_session_is_current(generation) &&
- state.widget is Some(current_widget) &&
- physical_equal(current_widget, widget) {
- widget.focus()
- }
- },
- // Viewer render runs at 100; focus runs later in the same native frame,
- // after a Code ViewZone has changed from display:none to display:block.
- priority=0,
- ),
- )
- true
-}
-
-///|
-/// Detaches only this provisional shell, then unmounts its exact local
-/// resources. Cleanup can itself emit ViewZone events, so all matching state
-/// is cleared before the first callback and stale transactions never touch a
-/// newer generation's same-id overlay.
-fn Viewer::retire_provisional_references_peek_shell(
- self : Viewer,
- state : ReferencesContributionState,
- generation : Int,
- widget : @references_browser.ReferencesPeekWidget,
- mounted_view : @view.View?,
- mounted_zone_id : String?,
- mounted_overlay : @editor_browser.OverlayWidget?,
- markdown_mounted : Bool,
-) -> Unit {
- if state.generation == generation &&
- state.widget is Some(current_widget) &&
- physical_equal(current_widget, widget) {
- state.widget = None
- state.zone_id = None
- state.overlay_widget = None
- state.markdown_projection_generation = None
- }
- match mounted_overlay {
- Some(overlay) => self.remove_overlay_widget_if_current(overlay) |> ignore
- None => ()
- }
- if markdown_mounted {
- @base_browser.remove_element(widget.get_dom_node())
- }
- widget.dispose()
- match (mounted_view, mounted_zone_id) {
- (Some(view), Some(zone_id)) =>
- view.change_view_zones(accessor => accessor.remove_zone(zone_id))
- _ => ()
- }
-}
-
-///|
-/// Mirrors `ZoneWidget.show`: reveal the anchor through the following model
-/// line after installing the whitespace. The range crosses the Peek ViewZone,
-/// so the scroll computation brings the whole widget into the viewport instead
-/// of leaving a newly opened shell clipped below the editor.
-fn Viewer::reveal_references_peek_zone(
- self : Viewer,
- position : @base_common.Position,
-) -> Unit {
- guard self.get_model() is Some(model) else { return }
- let line_number = position.line_number
- let end_line_number = (line_number + 1).min(model.get_line_count())
- if end_line_number == line_number {
- self.reveal_line_near_top(line_number)
- } else {
- self.reveal_lines(line_number, end_line_number)
- }
-}
-
-///|
-fn Viewer::references_peek_height_in_lines(self : Viewer) -> Double {
- let snapshot = self.configuration.snapshot()
- let requested = @references_browser.references_peek_height_in_lines.to_double()
- let line_height = snapshot.font_info.line_height.to_double()
- if snapshot.height <= 0.0 || line_height <= 0.0 {
- return requested
- }
- requested.min((snapshot.height / line_height * 0.8).max(12.0))
-}
-
-///|
-fn Viewer::definition_peek_request_is_current(
- self : Viewer,
- stamp : DefinitionPeekRequestStamp,
- source : @language.CancellationTokenSource,
-) -> Bool {
- guard !self.disposed && !source.token().is_cancellation_requested() else {
- return false
- }
- guard self.definition_contribution() is Some(definition) else { return false }
- guard self.references_contribution() is Some(references) else { return false }
- guard references.generation == stamp.generation else { return false }
- guard definition.peek_source is Some(current_source) else { return false }
- guard physical_equal(current_source, source) else { return false }
- guard self.get_model() is Some(model) else { return false }
- model.identity() == stamp.model_identity &&
- self.model_slot.generation == stamp.model_generation &&
- model.get_version_id() == stamp.model_version_id
-}
-
-///|
-fn Viewer::references_query_is_current(
- self : Viewer,
- stamp : ReferencesQueryStamp,
- source : @language.CancellationTokenSource,
-) -> Bool {
- guard !self.disposed && !source.token().is_cancellation_requested() else {
- return false
- }
- guard self.references_contribution() is Some(references) else { return false }
- guard references.generation == stamp.generation &&
- references.phase == PeekLoadingReferences(stamp.generation) &&
- references.query_source is Some(current_source) &&
- physical_equal(current_source, source) else {
- return false
- }
- guard self.get_model() is Some(model) else { return false }
- model.identity() == stamp.model_identity &&
- self.model_slot.generation == stamp.model_generation &&
- model.get_version_id() == stamp.model_version_id
-}
-
-///|
-fn Viewer::references_peek_session_is_current(
- self : Viewer,
- generation : Int,
-) -> Bool {
- guard self.references_contribution() is Some(state) &&
- state.widget is Some(widget) &&
- self.references_peek_shell_owner_is_current(generation, widget) else {
- return false
- }
- match
- (state.zone_id, state.overlay_widget, state.markdown_projection_generation) {
- (Some(_), Some(_), None) => self.current_code_browser_data() is Some(_)
- (None, None, Some(projection_generation)) =>
- match self.current_markdown_document() {
- Some(markdown) =>
- markdown.projection_generation() == projection_generation
- None => false
- }
- _ => false
- }
-}
-
-///|
-/// Validates the provisional shell owner without requiring its ViewZone or
-/// Markdown mount to have been published yet. Shell construction uses this
-/// after every synchronous browser callback boundary.
-fn Viewer::references_peek_shell_owner_is_current(
- self : Viewer,
- generation : Int,
- widget : @references_browser.ReferencesPeekWidget,
-) -> Bool {
- guard !self.disposed else { return false }
- guard self.references_contribution() is Some(state) else { return false }
- guard state.generation == generation &&
- state.widget is Some(current_widget) &&
- physical_equal(current_widget, widget) else {
- return false
- }
- guard state.session_key is Some(session_key) &&
- self.get_model() is Some(source_model) &&
- session_key.is_current(source_model, self.model_slot.generation) else {
- return false
- }
- state.mode is Some(_)
-}
-
-///|
-fn ReferencesContributionState::detach_group_previews(
- self : ReferencesContributionState,
-) -> DetachedReferencesGroupPreviews {
- let owners = self.group_preview_owners
- self.group_preview_owners = []
- let sources : Array[@language.CancellationTokenSource] = []
- let references : Array[@navigation_api.TextModelReference] = []
- // First clear every detached owner slot. Only after this loop returns may a
- // caller invoke cancellation or release callbacks.
- for owner in owners {
- owner.generation += 1
- match owner.source {
- Some(source) => sources.push(source)
- None => ()
- }
- match owner.reference {
- Some(reference) => references.push(reference)
- None => ()
- }
- owner.source = None
- owner.reference = None
- }
- { sources, references }
-}
-
-///|
-fn ReferencesGroupPreviewOwner::retire_source(
- self : ReferencesGroupPreviewOwner,
- source : @language.CancellationTokenSource,
- cancel~ : Bool,
-) -> Unit {
- match self.source {
- Some(current) if physical_equal(current, source) => {
- self.source = None
- source.dispose(cancel~)
- }
- _ => ()
- }
-}
-
-///|
-fn reference_group_row_previews(
- group : @references.ReferenceGroup,
- model : @model.TextModel,
-) -> Array[@references.ReferenceRowPreview] {
- let previews : Array[@references.ReferenceRowPreview] = []
- for item in group.references {
- previews.push(item.row_preview(Some(model)))
- }
- previews
-}
-
-///|
-fn Viewer::references_peek_group_is_current(
- self : Viewer,
- generation : Int,
- group : @references.ReferenceGroup,
-) -> Bool {
- guard self.references_peek_session_is_current(generation) else {
- return false
- }
- guard self.references_contribution() is Some(state) &&
- state.model is Some(model) else {
- return false
- }
- group.group_index >= 0 &&
- group.group_index < model.groups.length() &&
- model.groups[group.group_index] == group
-}
-
-///|
-fn Viewer::references_peek_group_slot_is_current(
- self : Viewer,
- generation : Int,
- group : @references.ReferenceGroup,
- owner_generation : Int,
-) -> Bool {
- guard self.references_peek_group_is_current(generation, group) else {
- return false
- }
- guard self.references_contribution() is Some(state) else { return false }
- guard group.group_index >= 0 &&
- group.group_index < state.group_preview_owners.length() else {
- return false
- }
- let owner = state.group_preview_owners[group.group_index]
- owner.group_index == group.group_index && owner.generation == owner_generation
-}
-
-///|
-fn Viewer::references_peek_group_request_is_current(
- self : Viewer,
- generation : Int,
- group : @references.ReferenceGroup,
- owner_generation : Int,
- source : @language.CancellationTokenSource,
-) -> Bool {
- guard self.references_peek_group_slot_is_current(
- generation, group, owner_generation,
- ) &&
- !source.token().is_cancellation_requested() else {
- return false
- }
- guard self.references_contribution() is Some(state) else { return false }
- let owner = state.group_preview_owners[group.group_index]
- match owner.source {
- Some(current_source) => physical_equal(current_source, source)
- None => false
- }
-}
-
-///|
-/// Lazily resolves one expanded resource group. The initially selected group
-/// reaches this path synchronously from `ReferencesPeekWidget::set_results`,
-/// after `install_references_peek_locations` has installed all owner slots.
-fn Viewer::load_references_peek_group(
- self : Viewer,
- group : @references.ReferenceGroup,
-) -> Unit {
- guard self.references_contribution() is Some(state) else { return }
- let generation = state.generation
- guard self.references_peek_group_is_current(generation, group) else { return }
- guard group.group_index >= 0 &&
- group.group_index < state.group_preview_owners.length() else {
- return
- }
- let owner = state.group_preview_owners[group.group_index]
- guard owner.group_index == group.group_index &&
- owner.source is None &&
- owner.reference is None else {
- return
- }
- match self.get_model() {
- Some(model) if model.uri.to_string() == group.uri_string => {
- guard state.widget is Some(widget) else { return }
- widget.set_group_previews(
- group.group_index,
- reference_group_row_previews(group, model),
- )
- return
- }
- _ => ()
- }
- guard self.services.text_model_resolver is Some(resolver) else { return }
- owner.generation += 1
- let owner_generation = owner.generation
- let source = @language.CancellationTokenSource()
- owner.source = Some(source)
- let task = state.launch_async_task
- task(async fn() noraise {
- let reference = resolver.resolve(group.uri, source.token())
- if !self.references_peek_group_request_is_current(
- generation, group, owner_generation, source,
- ) {
- owner.retire_source(source, cancel=true)
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- // Detach before disposing because token listeners may synchronously close
- // or replace the session.
- owner.source = None
- source.dispose()
- if !self.references_peek_group_slot_is_current(
- generation, group, owner_generation,
- ) {
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- match reference {
- None => ()
- Some(reference) => {
- let model = reference.model()
- if model.is_disposed() || model.uri.to_string() != group.uri_string {
- reference.dispose()
- return
- }
- guard self.references_peek_group_slot_is_current(
- generation, group, owner_generation,
- ) else {
- reference.dispose()
- return
- }
- owner.reference = Some(reference)
- guard self.references_contribution() is Some(current_state) &&
- current_state.widget is Some(widget) else {
- // A synchronous teardown cannot occur between the preceding guard
- // and this lookup today, but retain exact ownership if that changes.
- owner.reference = None
- reference.dispose()
- return
- }
- widget.set_group_previews(
- group.group_index,
- reference_group_row_previews(group, model),
- )
- }
- }
- })
-}
-
-///|
-fn Viewer::cancel_references_peek_preview_request(self : Viewer) -> Unit {
- guard self.references_contribution() is Some(state) else { return }
- let source = state.preview_source
- state.preview_source = None
- match source {
- Some(source) => source.dispose(cancel=true)
- None => ()
- }
-}
-
-///|
-/// Removes every installed preview owner slot without running callbacks.
-fn Viewer::detach_installed_references_peek_preview(
- self : Viewer,
-) -> DetachedReferencesPeekPreview? {
- guard self.references_contribution() is Some(state) else { return None }
- let restore_focus = match state.preview_viewer {
- Some(viewer) => viewer.has_text_focus()
- None => false
- }
- let detached : DetachedReferencesPeekPreview = {
- viewer: state.preview_viewer,
- reference: state.preview_reference,
- decorations: state.preview_decorations,
- layout_frame: state.preview_layout_frame,
- resize_observer: state.preview_resize_observer,
- restore_focus,
- }
- state.preview_resize_observer = None
- state.preview_layout_frame = None
- state.preview_viewer = None
- state.preview_reference = None
- state.preview_decorations = None
- Some(detached)
-}
-
-///|
-/// Releases only the installed child and lease. A replacement request keeps
-/// these alive until its own result is current and ready to commit.
-fn Viewer::clear_installed_references_peek_preview(self : Viewer) -> Bool {
- guard self.detach_installed_references_peek_preview() is Some(detached) else {
- return false
- }
- let restore_focus = detached.restore_focus
- detached.dispose()
- restore_focus
-}
-
-///|
-fn Viewer::load_references_peek_preview(
- self : Viewer,
- generation : Int,
- index : Int,
-) -> Unit {
- guard self.references_contribution() is Some(state) else { return }
- guard self.references_peek_session_is_current(generation) else { return }
- guard state.model is Some(result_model) else { return }
- guard index >= 0 && index < result_model.references.length() else { return }
- guard state.widget is Some(widget) else { return }
- let item = result_model.references[index]
- self.cancel_references_peek_preview_request()
- // Cancellation listeners are synchronous and may replace the complete Peek.
- // Revalidate the exact generation, model item, and widget before the stale
- // caller mutates selection/phase or installs a new request source.
- guard self.references_peek_preview_selection_is_current(
- generation, result_model, index, item, widget,
- ) else {
- return
- }
- state.selected_index = index
- widget.set_selected(item)
- // Expanding a previously collapsed group synchronously enters the root
- // callback, and focus movement can enter host listeners. Either path may
- // replace the whole session before `set_selected` returns.
- guard self.references_peek_preview_selection_is_current(
- generation, result_model, index, item, widget,
- ) else {
- return
- }
- widget.show_preview_loading()
- let location = item.location()
- state.preview_generation += 1
- let preview_generation = state.preview_generation
- state.phase = PeekLoadingPreview(generation, preview_generation)
- match self.get_model() {
- Some(model) if model.uri.to_string() == location.uri.to_string() => {
- self.schedule_references_peek_preview(
- generation, preview_generation, location, model,
- )
- return
- }
- _ => ()
- }
- guard self.services.text_model_resolver is Some(resolver) else {
- self.show_references_peek_preview_unavailable(
- generation, preview_generation, "No preview available",
- )
- return
- }
- let source = @language.CancellationTokenSource()
- state.preview_source = Some(source)
- let task = state.launch_async_task
- task(async fn() noraise {
- let reference = resolver.resolve(location.uri, source.token())
- if !self.references_peek_preview_request_is_current(
- generation, preview_generation, source,
- ) {
- state.retire_preview_source(source, cancel=true)
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- state.preview_source = None
- source.dispose()
- if !self.references_peek_session_is_current(generation) ||
- state.preview_generation != preview_generation {
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- match reference {
- None =>
- self.show_references_peek_preview_unavailable(
- generation, preview_generation, "No preview available",
- )
- Some(reference) => {
- let model = reference.model()
- if model.is_disposed() ||
- model.uri.to_string() != location.uri.to_string() {
- reference.dispose()
- self.show_references_peek_preview_unavailable(
- generation, preview_generation, "No preview available",
- )
- return
- }
- self.schedule_references_peek_preview(
- generation,
- preview_generation,
- location,
- model,
- reference~,
- )
- }
- }
- })
-}
-
-///|
-/// Exact selected-preview owner check shared by both synchronous boundaries in
-/// `load_references_peek_preview`.
-fn Viewer::references_peek_preview_selection_is_current(
- self : Viewer,
- generation : Int,
- result_model : @references.ReferencesModel,
- index : Int,
- item : @references.ReferenceItem,
- widget : @references_browser.ReferencesPeekWidget,
-) -> Bool {
- guard self.references_peek_session_is_current(generation) else {
- return false
- }
- guard self.references_contribution() is Some(state) &&
- state.model is Some(current_model) &&
- physical_equal(current_model, result_model) &&
- index >= 0 &&
- index < current_model.references.length() &&
- current_model.references[index] == item &&
- state.widget is Some(current_widget) &&
- physical_equal(current_widget, widget) else {
- return false
- }
- true
-}
-
-///|
-fn Viewer::references_peek_preview_request_is_current(
- self : Viewer,
- generation : Int,
- preview_generation : Int,
- source : @language.CancellationTokenSource,
-) -> Bool {
- guard self.references_peek_session_is_current(generation) else {
- return false
- }
- guard !source.token().is_cancellation_requested() else { return false }
- guard self.references_contribution() is Some(state) else { return false }
- guard state.preview_generation == preview_generation else { return false }
- match state.preview_source {
- Some(current_source) => physical_equal(current_source, source)
- None => false
- }
-}
-
-///|
-/// Defers the final child swap through the same Viewer-owned task host as
-/// provider and resolver work. That keeps stale reference disposal ordered
-/// with cancellation and prevents an unowned browser microtask from outliving
-/// the Viewer's DOM realm.
-#warnings("-unused_async")
-fn Viewer::schedule_references_peek_preview(
- self : Viewer,
- generation : Int,
- preview_generation : Int,
- location : @language.Location,
- model : @model.TextModel,
- reference? : @navigation_api.TextModelReference,
-) -> Unit {
- guard self.references_contribution() is Some(state) else {
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- let task = state.launch_async_task
- task(async fn() noraise {
- if !self.references_peek_preview_is_current(
- generation,
- preview_generation,
- location.uri.to_string(),
- model,
- ) {
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- guard state.widget is Some(widget) else {
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- let restore_preview_focus = self.clear_installed_references_peek_preview()
- guard state.model is Some(result_model) &&
- self.references_peek_preview_commit_is_current(
- generation,
- preview_generation,
- location.uri.to_string(),
- model,
- result_model,
- widget,
- ) else {
- match reference {
- Some(reference) => reference.dispose()
- None => ()
- }
- return
- }
- // Each step below can hand control to a listener that supersedes this
- // preview, so every step is followed by the same currency re-check against
- // the owners captured here. Binding it once keeps the interleaved
- // browser work readable.
- let still_current = () => {
- self.references_peek_preview_commit_is_current(
- generation,
- preview_generation,
- location.uri.to_string(),
- model,
- result_model,
- widget,
- )
- }
- let preview_host = widget.get_preview_host()
- let preview = Viewer(
- services=self.services,
- options=self.get_options(),
- initial_container=preview_host,
- role=PeekPreviewViewer,
- )
- let provisional : ProvisionalReferencesPeekPreview = {
- viewer: preview,
- reference,
- decorations: None,
- layout_frame: None,
- resize_observer: None,
- }
- preview.attach(preview_host)
- guard still_current() else {
- provisional.dispose()
- return
- }
- preview.set_model(Some(model))
- guard still_current() else {
- provisional.dispose()
- return
- }
- let range = model.validate_range(location.range)
- let decorations : Array[@model.ModelDeltaDecoration] = []
- let preview_uri = model.uri.to_string()
- for item in result_model.references {
- if item.uri_string == preview_uri {
- decorations.push({
- range: model.validate_range(item.range),
- options: ModelDecorationOptions(
- "moonbit-viewer-references-peek-reference-match",
- description="peek-reference-match",
- ),
- })
- }
- }
- // The selected match is intentionally an additional final decoration,
- // including when its range already received the all-reference class.
- decorations.push({
- range,
- options: ModelDecorationOptions(
- "moonbit-viewer-references-peek-reference-match-selected",
- description="peek-reference-match-selected",
- ),
- })
- provisional.decorations = Some(
- preview.create_decorations_collection(decorations~),
- )
- guard still_current() else {
- provisional.dispose()
- return
- }
- let start = range.get_start_position()
- preview.set_position(
- start,
- source=if state.mode == Some(Definitions) {
- "peekDefinition"
- } else {
- "peekReferences"
- },
- )
- guard still_current() else {
- provisional.dispose()
- return
- }
- preview.reveal_range_in_center_if_outside_viewport(
- Range(start.line_number, start.column, start.line_number, start.column),
- )
- guard still_current() else {
- provisional.dispose()
- return
- }
- preview.handle_initialized()
- guard still_current() else {
- provisional.dispose()
- return
- }
- provisional.resize_observer = @base_browser.observe_element_resize(
- preview_host,
- () => {
- if still_current() &&
- state.preview_viewer is Some(current_preview) &&
- physical_equal(current_preview, preview) {
- preview.layout()
- }
- },
- )
- guard still_current() else {
- provisional.dispose()
- return
- }
- provisional.layout_frame = Some(
- @base_browser.schedule_at_next_animation_frame(() => {
- if still_current() &&
- state.preview_viewer is Some(current_preview) &&
- physical_equal(current_preview, preview) {
- preview.layout()
- }
- }),
- )
- guard still_current() else {
- provisional.dispose()
- return
- }
- widget.show_preview_ready()
- guard still_current() else {
- provisional.dispose()
- return
- }
- if restore_preview_focus {
- preview.focus()
- }
- guard still_current() else {
- provisional.dispose()
- return
- }
- // No callback boundary remains between these assignments. Publish the
- // complete owner set atomically from the root controller's perspective.
- provisional.publish(state)
- state.phase = PeekShown(generation, preview_generation)
- })
-}
-
-///|
-fn Viewer::references_peek_preview_commit_is_current(
- self : Viewer,
- generation : Int,
- preview_generation : Int,
- expected_uri : String,
- model : @model.TextModel,
- result_model : @references.ReferencesModel,
- widget : @references_browser.ReferencesPeekWidget,
-) -> Bool {
- guard self.references_peek_preview_is_current(
- generation, preview_generation, expected_uri, model,
- ) else {
- return false
- }
- guard self.references_contribution() is Some(state) &&
- state.model is Some(current_model) &&
- physical_equal(current_model, result_model) &&
- state.widget is Some(current_widget) &&
- physical_equal(current_widget, widget) else {
- return false
- }
- true
-}
-
-///|
-fn Viewer::references_peek_preview_is_current(
- self : Viewer,
- generation : Int,
- preview_generation : Int,
- expected_uri : String,
- model : @model.TextModel,
-) -> Bool {
- guard self.references_peek_session_is_current(generation) else {
- return false
- }
- guard !model.is_disposed() && model.uri.to_string() == expected_uri else {
- return false
- }
- guard self.references_contribution() is Some(state) else { return false }
- state.preview_generation == preview_generation
-}
-
-///|
-fn Viewer::show_references_peek_preview_unavailable(
- self : Viewer,
- generation : Int,
- preview_generation : Int,
- message : String,
-) -> Unit {
- guard self.references_peek_session_is_current(generation) else { return }
- guard self.references_contribution() is Some(state) else { return }
- guard state.preview_generation == preview_generation else { return }
- guard state.widget is Some(widget) else { return }
- let had_preview_focus = self.clear_installed_references_peek_preview()
- guard self.references_peek_session_is_current(generation) &&
- state.preview_generation == preview_generation &&
- state.widget is Some(current_widget) &&
- physical_equal(current_widget, widget) else {
- return
- }
- state.phase = PeekUnavailable(generation, preview_generation)
- widget.show_preview_unavailable(message)
- if had_preview_focus {
- widget.focus()
- }
-}
-
-///|
-fn Viewer::references_peek_item_is_current(
- self : Viewer,
- item : @references.ReferenceItem,
-) -> Bool {
- match self.references_contribution() {
- Some(state) =>
- match state.model {
- Some(model) =>
- item.flat_index >= 0 &&
- item.flat_index < model.references.length() &&
- model.references[item.flat_index] == item
- None => false
- }
- None => false
- }
-}
-
-///|
-fn Viewer::select_references_peek_item(
- self : Viewer,
- item : @references.ReferenceItem,
-) -> Unit {
- if self.references_peek_item_is_current(item) {
- self.select_references_peek_result(item.flat_index)
- }
-}
-
-///|
-fn Viewer::select_references_peek_result(self : Viewer, index : Int) -> Unit {
- guard self.references_contribution() is Some(state) else { return }
- guard state.model is Some(result_model) else { return }
- let generation = state.generation
- guard index >= 0 && index < result_model.references.length() else { return }
- if index == state.selected_index &&
- (state.preview_viewer is Some(_) || state.preview_source is Some(_)) {
- return
- }
- self.load_references_peek_preview(generation, index)
-}
-
-///|
-fn Viewer::move_references_peek_result(self : Viewer, delta : Int) -> Unit {
- guard self.references_contribution() is Some(state) else { return }
- guard state.model is Some(result_model) else { return }
- guard result_model.references.length() > 0 else { return }
- let current = if state.selected_index >= 0 &&
- state.selected_index < result_model.references.length() {
- result_model.references[state.selected_index]
- } else {
- result_model.references[0]
- }
- let next = if delta < 0 {
- result_model.previous_reference(current)
- } else {
- result_model.next_reference(current)
- }
- match next {
- Some(item) => self.select_references_peek_item(item)
- None => ()
- }
-}
-
-///|
-fn Viewer::confirm_references_peek_result(self : Viewer, index : Int) -> Unit {
- guard self.references_contribution() is Some(references) else { return }
- guard references.model is Some(result_model) else { return }
- guard index >= 0 && index < result_model.references.length() else { return }
- self.confirm_references_peek_item(result_model.references[index], Current)
-}
-
-///|
-/// Shared current-model/opener path for Definition and References results.
-/// Mode-specific feedback remains with the Definition wrapper.
-async fn Viewer::open_peek_location(
- self : Viewer,
- location : @language.Location,
- mode : @navigation_api.OpenLocationMode,
- source : String,
-) -> Bool noraise {
- guard !self.disposed else { return false }
- guard self.get_model() is Some(source_model) else { return false }
- if mode == Current && source_model.uri.to_string() == location.uri.to_string() {
- let range = source_model.validate_range(location.range)
- let start = range.get_start_position()
- self.set_position(start, source~)
- self.reveal_range_in_center_if_outside_viewport(
- Range(start.line_number, start.column, start.line_number, start.column),
- )
- self.focus()
- self.show_navigation_target_highlight(source_model, range)
- return true
- }
- match self.services.location_opener {
- Some(opener) =>
- opener.open({ source_viewer_id: self.get_id(), location, mode })
- None => false
- }
-}
-
-///|
-fn Viewer::confirm_references_peek_item(
- self : Viewer,
- item : @references.ReferenceItem,
- mode : @navigation_api.OpenLocationMode,
-) -> Unit {
- guard self.references_peek_item_is_current(item) else { return }
- guard self.references_contribution() is Some(references) &&
- references.mode is Some(peek_mode) else {
- return
- }
- guard self.get_model() is Some(model) else { return }
- let location = item.location()
- let model_identity = model.identity()
- let model_generation = self.model_slot.generation
- let model_version_id = model.get_version_id()
- let task = references.launch_async_task
- let definition_open_generation = match peek_mode {
- Definitions =>
- match self.definition_contribution() {
- Some(definition) => {
- definition.invalidate_open_feedback()
- definition.open_generation
- }
- None => return
- }
- References => -1
- }
- let closed_generation = references.generation + 1
- self.close_references_peek(restore_focus=true)
- task(async fn() noraise {
- guard !self.disposed &&
- references.generation == closed_generation &&
- self.get_model() is Some(current_model) &&
- current_model.identity() == model_identity &&
- self.model_slot.generation == model_generation &&
- current_model.get_version_id() == model_version_id else {
- return
- }
- match peek_mode {
- Definitions => {
- guard self.definition_contribution() is Some(definition) &&
- definition.open_generation == definition_open_generation else {
- return
- }
- self.open_definition_location(location, mode) |> ignore
- }
- References =>
- self.open_peek_location(location, mode, "showReferences") |> ignore
- }
- })
-}
-
-///|
-/// Compatibility seams retained for the Definition regression suite while
-/// production callbacks use the shared References names.
-fn Viewer::cancel_definition_peek_preview_request(self : Viewer) -> Unit {
- self.cancel_references_peek_preview_request()
-}
-
-///|
-fn Viewer::move_definition_peek_result(self : Viewer, delta : Int) -> Unit {
- self.move_references_peek_result(delta)
-}
-
-///|
-fn Viewer::confirm_definition_peek_result(self : Viewer, index : Int) -> Unit {
- self.confirm_references_peek_result(index)
-}
-
-///|
-fn Viewer::close_references_peek(self : Viewer, restore_focus~ : Bool) -> Unit {
- guard self.references_contribution() is Some(state) else { return }
- state.generation += 1
- let closing_generation = state.generation
- state.preview_generation += 1
- state.phase = PeekClosed
-
- // Detach the complete old session before the first cancellation/disposal
- // callback. Any callback may synchronously open a new Peek; teardown below
- // must then touch only these captured old resources.
- let definition_source = match self.definition_contribution() {
- Some(definition) => {
- let source = definition.peek_source
- definition.peek_source = None
- source
- }
- None => None
- }
- let query_source = state.query_source
- state.query_source = None
- let preview_source = state.preview_source
- state.preview_source = None
- let focus_frame = state.focus_frame
- state.focus_frame = None
- let widget = state.widget
- state.widget = None
- let zone_id = state.zone_id
- state.zone_id = None
- // ViewZone ids are model-scoped and may collide after a reentrant model
- // replacement. Retain the exact owning View before any cancellation or
- // lease-release callback can swap the root presentation.
- let zone_view = match zone_id {
- Some(_) =>
- match self.current_code_browser_data() {
- Some(browser) => Some(browser.view)
- None => None
- }
- None => None
- }
- let overlay_widget = state.overlay_widget
- state.overlay_widget = None
- let markdown_projection_generation = state.markdown_projection_generation
- state.markdown_projection_generation = None
- let detached_preview = self.detach_installed_references_peek_preview()
- let detached_groups = state.detach_group_previews()
- state.model = None
- state.selected_index = -1
- state.mode = None
- state.session_key = None
-
- match definition_source {
- Some(source) => source.dispose(cancel=true)
- None => ()
- }
- match query_source {
- Some(source) => source.dispose(cancel=true)
- None => ()
- }
- match preview_source {
- Some(source) => source.dispose(cancel=true)
- None => ()
- }
- detached_groups.cancel_sources()
- match focus_frame {
- Some(frame) => frame.dispose()
- None => ()
- }
- // The nested Viewer and selected lease are children of the shell. Then
- // release group leases and the explicit decoration owner in the locked D6
- // order, while the shell listeners and host node are still alive.
- match detached_preview {
- Some(detached) => detached.dispose_viewer()
- None => ()
- }
- match detached_preview {
- Some(detached) => detached.dispose_reference()
- None => ()
- }
- detached_groups.dispose_references()
- match detached_preview {
- Some(detached) => detached.dispose_decorations()
- None => ()
- }
- match overlay_widget {
- Some(overlay_widget) =>
- self.remove_overlay_widget_if_current(overlay_widget) |> ignore
- None => ()
- }
- match markdown_projection_generation {
- Some(_) =>
- match widget {
- Some(widget) => @base_browser.remove_element(widget.get_dom_node())
- None => ()
- }
- None => ()
- }
- match widget {
- Some(widget) => widget.dispose()
- None => ()
- }
- match (zone_view, zone_id) {
- (Some(view), Some(zone_id)) => {
- view.change_view_zones(accessor => accessor.remove_zone(zone_id))
- if !self.disposed {
- // The root wrapper normally owns this render schedule. Teardown uses
- // the captured model-scoped View directly for identity safety.
- self.schedule_render()
- }
- }
- _ => ()
- }
- if restore_focus &&
- !self.disposed &&
- state.generation == closing_generation &&
- state.phase == PeekClosed &&
- state.widget is None {
- self.focus()
- }
-}
-
-///|
-fn Viewer::close_definition_peek(self : Viewer, restore_focus~ : Bool) -> Unit {
- self.close_references_peek(restore_focus~)
-}
-
-///|
-fn Viewer::definition_peek_phase_for_test(
- self : Viewer,
-) -> @references.ReferencesPeekPhase {
- match self.references_contribution() {
- Some(state) => state.phase
- None => PeekClosed
- }
-}
-
-///|
-let _definition_peek_test_seam_anchor : Unit = ignore(
- (
- Viewer::definition_peek_phase_for_test,
- Viewer::cancel_definition_peek_preview_request,
- Viewer::move_definition_peek_result,
- Viewer::confirm_definition_peek_result,
- ),
-)
diff --git a/editor/viewer/diff_editor_facade.mbt b/editor/viewer/diff_editor_facade.mbt
new file mode 100644
index 000000000..105855415
--- /dev/null
+++ b/editor/viewer/diff_editor_facade.mbt
@@ -0,0 +1,279 @@
+///|
+/// The two caller-owned models installed as one atomic diff-editor pair.
+pub(all) struct DiffEditorModel {
+ original : @model.TextModel
+ modified : @model.TextModel
+}
+
+///|
+pub fn DiffEditorModel::DiffEditorModel(
+ original : @model.TextModel,
+ modified : @model.TextModel,
+) -> DiffEditorModel {
+ { original, modified }
+}
+
+///|
+pub(all) enum DiffEditorLayout {
+ SideBySide
+ Inline
+} derive(Eq, Debug)
+
+///|
+pub(all) enum DiffWordWrap {
+ Inherit
+ Off
+} derive(Eq, Debug)
+
+///|
+/// Public construction options for the comparison widget. Standalone editor
+/// presentation remains an opaque ViewerOptions value; diff policy is mapped
+/// explicitly into the internal package.
+struct DiffEditorOptions {
+ editor_options : ViewerOptions
+ layout : DiffEditorLayout
+ automatic_inline : Bool
+ automatic_inline_width : Double
+ sash_enabled : Bool
+ split_ratio : Double
+ diff_word_wrap : DiffWordWrap
+ overview_ruler : Bool
+ ignore_trim_whitespace : Bool
+}
+
+///|
+pub fn DiffEditorOptions::DiffEditorOptions(
+ editor_options? : ViewerOptions = ViewerOptions::default().with_soft_wrap(
+ true,
+ ),
+ layout? : DiffEditorLayout = SideBySide,
+ automatic_inline? : Bool = true,
+ automatic_inline_width? : Double = 900.0,
+ sash_enabled? : Bool = true,
+ split_ratio? : Double = 0.5,
+ diff_word_wrap? : DiffWordWrap = Inherit,
+ overview_ruler? : Bool = true,
+ ignore_trim_whitespace? : Bool = true,
+) -> DiffEditorOptions {
+ {
+ editor_options,
+ layout,
+ automatic_inline,
+ automatic_inline_width: automatic_inline_width.max(0.0),
+ sash_enabled,
+ split_ratio: split_ratio.clamp(min=0.1, max=0.9),
+ diff_word_wrap,
+ overview_ruler,
+ ignore_trim_whitespace,
+ }
+}
+
+///|
+pub fn DiffEditorOptions::default() -> DiffEditorOptions {
+ DiffEditorOptions()
+}
+
+///|
+pub fn DiffEditorOptions::with_layout(
+ self : DiffEditorOptions,
+ layout : DiffEditorLayout,
+) -> DiffEditorOptions {
+ { ..self, layout, }
+}
+
+///|
+pub fn DiffEditorOptions::with_editor_options(
+ self : DiffEditorOptions,
+ editor_options : ViewerOptions,
+) -> DiffEditorOptions {
+ { ..self, editor_options, }
+}
+
+///|
+pub fn DiffEditorOptions::layout(self : DiffEditorOptions) -> DiffEditorLayout {
+ self.layout
+}
+
+///|
+fn DiffEditorLayout::internal(
+ self : DiffEditorLayout,
+) -> @diff_editor.DiffEditorLayout {
+ match self {
+ SideBySide => SideBySide
+ Inline => Inline
+ }
+}
+
+///|
+fn DiffWordWrap::internal(self : DiffWordWrap) -> @diff_editor.DiffWordWrap {
+ match self {
+ Inherit => Inherit
+ Off => Off
+ }
+}
+
+///|
+fn DiffEditorOptions::internal(
+ self : DiffEditorOptions,
+) -> @diff_editor.DiffEditorOptions {
+ DiffEditorOptions(
+ layout=self.layout.internal(),
+ automatic_inline=self.automatic_inline,
+ automatic_inline_width=self.automatic_inline_width,
+ sash_enabled=self.sash_enabled,
+ split_ratio=self.split_ratio,
+ diff_word_wrap=self.diff_word_wrap.internal(),
+ overview_ruler=self.overview_ruler,
+ ignore_trim_whitespace=self.ignore_trim_whitespace,
+ )
+}
+
+///|
+/// Public opaque façade. It owns no child public Viewer; the internal widget
+/// directly owns two CodeEditorWidget kernels and the shared diff ViewModel.
+struct DiffEditor {
+ widget : @diff_editor.DiffEditorWidget
+ owned_core_provider : @diff.CoreDocumentDiffProvider?
+}
+
+///|
+pub fn DiffEditor::create(
+ host : @rdom.Element,
+ services? : ViewerServices,
+ options? : DiffEditorOptions = DiffEditorOptions::default(),
+ provider? : &@diff.DocumentDiffProvider,
+) -> DiffEditor {
+ let (provider, owned_core_provider) = match provider {
+ Some(provider) => (provider, None)
+ None => {
+ let core = @diff.CoreDocumentDiffProvider()
+ let provider : &@diff.DocumentDiffProvider = core
+ (provider, Some(core))
+ }
+ }
+ let widget = match services {
+ Some(services) =>
+ @diff_editor.DiffEditorWidget::create(
+ host,
+ provider,
+ services=services.code_services(),
+ editor_options=options.editor_options.code_options(),
+ options=options.internal(),
+ )
+ None =>
+ @diff_editor.DiffEditorWidget::create(
+ host,
+ provider,
+ editor_options=options.editor_options.code_options(),
+ options=options.internal(),
+ )
+ }
+ { widget, owned_core_provider }
+}
+
+///|
+pub fn DiffEditor::set_model(
+ self : DiffEditor,
+ model : DiffEditorModel?,
+) -> DiffEditorModel? {
+ self.widget
+ .set_model(
+ model.map(model => { original: model.original, modified: model.modified }),
+ )
+ .map(previous => { original: previous.original, modified: previous.modified })
+}
+
+///|
+pub fn DiffEditor::get_model(self : DiffEditor) -> DiffEditorModel? {
+ self.widget
+ .get_model()
+ .map(model => { original: model.original, modified: model.modified })
+}
+
+///|
+pub fn DiffEditor::set_diff_provider(
+ self : DiffEditor,
+ provider : &@diff.DocumentDiffProvider,
+) -> Unit {
+ self.widget.set_diff_provider(provider)
+}
+
+///|
+pub fn DiffEditor::update_options(
+ self : DiffEditor,
+ options : DiffEditorOptions,
+) -> Unit {
+ self.widget.update_options(
+ options.editor_options.code_options(),
+ options.internal(),
+ )
+}
+
+///|
+pub fn DiffEditor::get_layout(self : DiffEditor) -> DiffEditorLayout {
+ match self.widget.get_layout() {
+ SideBySide => SideBySide
+ Inline => Inline
+ }
+}
+
+///|
+pub fn DiffEditor::get_diff(self : DiffEditor) -> @diff.DocumentDiff? {
+ self.widget.get_diff()
+}
+
+///|
+pub fn DiffEditor::is_diff_up_to_date(self : DiffEditor) -> Bool {
+ self.widget.is_diff_up_to_date()
+}
+
+///|
+pub fn DiffEditor::on_did_update_diff(
+ self : DiffEditor,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_update_diff(listener)
+}
+
+///|
+pub fn DiffEditor::reveal_next_change(self : DiffEditor) -> Bool {
+ self.widget.reveal_next_change()
+}
+
+///|
+pub fn DiffEditor::reveal_previous_change(self : DiffEditor) -> Bool {
+ self.widget.reveal_previous_change()
+}
+
+///|
+pub fn DiffEditor::layout(self : DiffEditor) -> Unit {
+ self.widget.layout()
+}
+
+///|
+pub fn DiffEditor::focus(self : DiffEditor) -> Unit {
+ self.widget.focus()
+}
+
+///|
+pub fn DiffEditor::dispose(self : DiffEditor) -> Unit {
+ self.widget.dispose()
+ // The empty Core provider has no disposal work; touching the field here
+ // documents and preserves its lifetime until after the ViewModel is gone.
+ match self.owned_core_provider {
+ Some(_) => ()
+ None => ()
+ }
+}
+
+///|
+pub extend DiffEditorLayout with Eq::{equal, not_equal}
+
+///|
+pub extend DiffEditorLayout with Debug::{to_repr}
+
+///|
+pub extend DiffWordWrap with Eq::{equal, not_equal}
+
+///|
+pub extend DiffWordWrap with Debug::{to_repr}
diff --git a/editor/viewer/diff_provider/moondiff/moon.pkg b/editor/viewer/diff_provider/moondiff/moon.pkg
new file mode 100644
index 000000000..635399a33
--- /dev/null
+++ b/editor/viewer/diff_provider/moondiff/moon.pkg
@@ -0,0 +1,7 @@
+import {
+ "moonbitlang/editor/base/common" @base_common,
+ "moonbitlang/editor/viewer/common/diff",
+ "moonbitlang/editor/viewer/common/model",
+ "moonbit-community/moondiff/mbtdiff",
+ "moonbit-community/moondiff/tokdiff",
+}
diff --git a/editor/viewer/diff_provider/moondiff/pkg.generated.mbti b/editor/viewer/diff_provider/moondiff/pkg.generated.mbti
new file mode 100644
index 000000000..f59708873
--- /dev/null
+++ b/editor/viewer/diff_provider/moondiff/pkg.generated.mbti
@@ -0,0 +1,53 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbitlang/editor/viewer/diff_provider/moondiff"
+
+import {
+ "moonbitlang/core/debug",
+ "moonbitlang/editor/base/common",
+ "moonbitlang/editor/viewer/common/diff",
+ "moonbitlang/editor/viewer/common/model",
+}
+
+// Values
+
+// Errors
+
+// Types and methods
+pub(all) enum MoonDiffMode {
+ Core
+ Token
+ Tree
+} derive(Eq, @debug.Debug)
+pub fn MoonDiffMode::equal(Self, Self) -> Bool
+pub fn MoonDiffMode::not_equal(Self, Self) -> Bool
+pub fn MoonDiffMode::to_repr(Self) -> @debug.Repr
+
+pub(all) struct MoonDiffStatus {
+ requested : MoonDiffMode
+ effective : MoonDiffMode
+ fell_back : Bool
+} derive(Eq, @debug.Debug)
+pub fn MoonDiffStatus::equal(Self, Self) -> Bool
+pub fn MoonDiffStatus::not_equal(Self, Self) -> Bool
+pub fn MoonDiffStatus::to_repr(Self) -> @debug.Repr
+
+pub struct MoonDocumentDiffProvider {
+ mut mode : MoonDiffMode
+ mut ignore_comments : Bool
+ mut status : MoonDiffStatus
+ did_change : @common.Emitter[Unit]
+ mut disposed : Bool
+}
+pub fn MoonDocumentDiffProvider::MoonDocumentDiffProvider(mode? : MoonDiffMode, ignore_comments? : Bool) -> Self
+pub fn MoonDocumentDiffProvider::as_document_diff_provider(Self) -> &@diff.DocumentDiffProvider
+pub fn MoonDocumentDiffProvider::dispose(Self) -> Unit
+pub fn MoonDocumentDiffProvider::get_ignore_comments(Self) -> Bool
+pub fn MoonDocumentDiffProvider::get_mode(Self) -> MoonDiffMode
+pub fn MoonDocumentDiffProvider::get_status(Self) -> MoonDiffStatus
+pub fn MoonDocumentDiffProvider::set_ignore_comments(Self, Bool) -> Unit
+pub fn MoonDocumentDiffProvider::set_mode(Self, MoonDiffMode) -> Unit
+pub impl @diff.DocumentDiffProvider for MoonDocumentDiffProvider
+
+// Type aliases
+
+// Traits
diff --git a/editor/viewer/diff_provider/moondiff/provider.mbt b/editor/viewer/diff_provider/moondiff/provider.mbt
new file mode 100644
index 000000000..6aebfb1f0
--- /dev/null
+++ b/editor/viewer/diff_provider/moondiff/provider.mbt
@@ -0,0 +1,526 @@
+///|
+/// Algorithms offered by the optional MoonBit-aware provider. Core remains a
+/// first-class choice so one host-owned toolbar can keep a single provider
+/// instance while switching modes.
+pub(all) enum MoonDiffMode {
+ Core
+ Token
+ Tree
+} derive(Eq, Debug)
+
+///|
+/// The result mode is host-visible product state. Tree can legitimately
+/// degrade to Token, while invalid specialized output degrades to Core.
+pub(all) struct MoonDiffStatus {
+ requested : MoonDiffMode
+ effective : MoonDiffMode
+ fell_back : Bool
+} derive(Eq, Debug)
+
+///|
+/// Optional synchronous provider used only by the desktop review host. The
+/// diff editor core depends exclusively on DocumentDiffProvider and therefore
+/// has no MoonDiff modes, controls, or fallback policy.
+pub struct MoonDocumentDiffProvider {
+ mut mode : MoonDiffMode
+ mut ignore_comments : Bool
+ mut status : MoonDiffStatus
+ did_change : @base_common.Emitter[Unit]
+ mut disposed : Bool
+}
+
+///|
+pub fn MoonDocumentDiffProvider::MoonDocumentDiffProvider(
+ mode? : MoonDiffMode = Core,
+ ignore_comments? : Bool = true,
+) -> MoonDocumentDiffProvider {
+ {
+ mode,
+ ignore_comments,
+ status: { requested: mode, effective: mode, fell_back: false },
+ did_change: Emitter(),
+ disposed: false,
+ }
+}
+
+///|
+pub fn MoonDocumentDiffProvider::set_mode(
+ self : MoonDocumentDiffProvider,
+ mode : MoonDiffMode,
+) -> Unit {
+ if self.disposed || self.mode == mode {
+ return
+ }
+ self.mode = mode
+ self.status = { requested: mode, effective: mode, fell_back: false }
+ self.did_change.fire(())
+}
+
+///|
+pub fn MoonDocumentDiffProvider::get_mode(
+ self : MoonDocumentDiffProvider,
+) -> MoonDiffMode {
+ self.mode
+}
+
+///|
+pub fn MoonDocumentDiffProvider::set_ignore_comments(
+ self : MoonDocumentDiffProvider,
+ ignore_comments : Bool,
+) -> Unit {
+ if self.disposed || self.ignore_comments == ignore_comments {
+ return
+ }
+ self.ignore_comments = ignore_comments
+ self.did_change.fire(())
+}
+
+///|
+pub fn MoonDocumentDiffProvider::get_ignore_comments(
+ self : MoonDocumentDiffProvider,
+) -> Bool {
+ self.ignore_comments
+}
+
+///|
+pub fn MoonDocumentDiffProvider::get_status(
+ self : MoonDocumentDiffProvider,
+) -> MoonDiffStatus {
+ self.status
+}
+
+///|
+/// Explicit host-facing coercion keeps the optional package independently
+/// checkable while exposing only the common diff contract to DiffEditor.
+pub fn MoonDocumentDiffProvider::as_document_diff_provider(
+ self : MoonDocumentDiffProvider,
+) -> &@diff.DocumentDiffProvider {
+ self
+}
+
+///|
+pub fn MoonDocumentDiffProvider::dispose(
+ self : MoonDocumentDiffProvider,
+) -> Unit {
+ if self.disposed {
+ return
+ }
+ self.disposed = true
+ self.did_change.dispose()
+}
+
+///|
+fn snapshot_lines(snapshot : @model.TextSnapshot) -> Array[String] {
+ let lines = []
+ for line_number in 1..<=snapshot.line_count() {
+ lines.push(snapshot.get_line_content(line_number))
+ }
+ lines
+}
+
+///|
+fn same_line_change_shape(
+ first : @diff.DocumentDiff,
+ second : @diff.DocumentDiff,
+) -> Bool {
+ if first.changes.length() != second.changes.length() {
+ return false
+ }
+ for index in 0.. (Array[Int], Array[Int]) {
+ let leading : Array[Int] = []
+ let trailing : Array[Int] = []
+ let mut saw_content = false
+ for character in line {
+ if character.is_whitespace() {
+ if saw_content {
+ trailing.push(character.to_int())
+ } else {
+ leading.push(character.to_int())
+ }
+ } else {
+ saw_content = true
+ trailing.clear()
+ }
+ }
+ (leading, trailing)
+}
+
+///|
+fn paired_change_has_edge_whitespace_difference(
+ strict : @diff.DocumentDiff,
+ original : @model.TextSnapshot,
+ modified : @model.TextSnapshot,
+) -> Bool {
+ for change in strict.changes {
+ if change.original.length() != change.modified.length() {
+ continue
+ }
+ for offset in 0.. Bool {
+ start >= 0 && len >= 0 && start <= total && len <= total - start
+}
+
+///|
+fn valid_line(
+ line : @tokdiff.DiffLine,
+ expected_index : Int,
+ source : Array[String],
+) -> Bool {
+ line.index == expected_index &&
+ expected_index >= 0 &&
+ expected_index < source.length() &&
+ line.text() == source[expected_index]
+}
+
+///|
+fn changed_ranges(line : @tokdiff.DiffLine) -> Array[@base_common.Range] {
+ let ranges = []
+ let mut column = 1
+ for segment in line.segments {
+ let end_column = column + segment.text.length()
+ if segment.kind is Changed && end_column > column {
+ ranges.push(
+ @base_common.Range(line.index + 1, column, line.index + 1, end_column),
+ )
+ }
+ column = end_column
+ }
+ ranges
+}
+
+///|
+fn paired_inner_changes(
+ original : @tokdiff.DiffLine,
+ modified : @tokdiff.DiffLine,
+) -> Array[@diff.RangeMapping]? {
+ let original_ranges = changed_ranges(original)
+ let modified_ranges = changed_ranges(modified)
+ if original_ranges.is_empty() || modified_ranges.is_empty() {
+ return None
+ }
+ let mappings = []
+ let count = original_ranges.length().min(modified_ranges.length())
+ for index in 0.. @diff.DetailedLineRangeMapping {
+ DetailedLineRangeMapping(
+ LineRange(original.index + 1, original.index + 2),
+ LineRange(modified.index + 1, modified.index + 2),
+ paired_inner_changes(original, modified),
+ )
+}
+
+///|
+fn old_only_change(
+ original : @tokdiff.DiffLine,
+ modified_cursor : Int,
+) -> @diff.DetailedLineRangeMapping {
+ DetailedLineRangeMapping(
+ LineRange(original.index + 1, original.index + 2),
+ LineRange(modified_cursor + 1, modified_cursor + 1),
+ None,
+ )
+}
+
+///|
+fn new_only_change(
+ modified : @tokdiff.DiffLine,
+ original_cursor : Int,
+) -> @diff.DetailedLineRangeMapping {
+ DetailedLineRangeMapping(
+ LineRange(original_cursor + 1, original_cursor + 1),
+ LineRange(modified.index + 1, modified.index + 2),
+ None,
+ )
+}
+
+///|
+fn append_rows(
+ rows : Array[@tokdiff.DiffRow],
+ render_changes : Bool,
+ original_lines : Array[String],
+ modified_lines : Array[String],
+ original_cursor : Int,
+ modified_cursor : Int,
+ changes : Array[@diff.DetailedLineRangeMapping],
+ alignments : Array[@diff.LineRangeMapping],
+) -> (Int, Int)? {
+ let mut old_cursor = original_cursor
+ let mut new_cursor = modified_cursor
+ for row in rows {
+ match row {
+ Paired(original, modified) => {
+ if !valid_line(original, old_cursor, original_lines) ||
+ !valid_line(modified, new_cursor, modified_lines) {
+ return None
+ }
+ if render_changes {
+ changes.push(paired_change(original, modified))
+ }
+ old_cursor += 1
+ new_cursor += 1
+ }
+ OldOnly(original) => {
+ if !valid_line(original, old_cursor, original_lines) {
+ return None
+ }
+ let mapping = old_only_change(original, new_cursor)
+ if render_changes {
+ changes.push(mapping)
+ } else {
+ alignments.push(mapping.line_mapping())
+ }
+ old_cursor += 1
+ }
+ NewOnly(modified) => {
+ if !valid_line(modified, new_cursor, modified_lines) {
+ return None
+ }
+ let mapping = new_only_change(modified, old_cursor)
+ if render_changes {
+ changes.push(mapping)
+ } else {
+ alignments.push(mapping.line_mapping())
+ }
+ new_cursor += 1
+ }
+ }
+ }
+ Some((old_cursor, new_cursor))
+}
+
+///|
+fn adapt_document(
+ document : @tokdiff.DiffDocument,
+ original_lines : Array[String],
+ modified_lines : Array[String],
+) -> @diff.DocumentDiff? {
+ let changes = []
+ let alignments = []
+ for hunk in document.hunks {
+ if !valid_hunk_range(hunk.old_start, hunk.old_len, original_lines.length()) ||
+ !valid_hunk_range(hunk.new_start, hunk.new_len, modified_lines.length()) {
+ return None
+ }
+ let mut old_cursor = hunk.old_start
+ let mut new_cursor = hunk.new_start
+ for block in hunk.blocks {
+ match block {
+ ContextBlock(lines) =>
+ for line in lines {
+ if line.old_index != old_cursor ||
+ line.new_index != new_cursor ||
+ old_cursor >= original_lines.length() ||
+ new_cursor >= modified_lines.length() ||
+ line.text != original_lines[old_cursor] ||
+ line.text != modified_lines[new_cursor] {
+ return None
+ }
+ old_cursor += 1
+ new_cursor += 1
+ }
+ ChangeBlock(rows) =>
+ match
+ append_rows(
+ rows, true, original_lines, modified_lines, old_cursor, new_cursor,
+ changes, alignments,
+ ) {
+ Some((next_old, next_new)) => {
+ old_cursor = next_old
+ new_cursor = next_new
+ }
+ None => return None
+ }
+ IgnoredBlock(rows) =>
+ match
+ append_rows(
+ rows, false, original_lines, modified_lines, old_cursor, new_cursor,
+ changes, alignments,
+ ) {
+ Some((next_old, next_new)) => {
+ old_cursor = next_old
+ new_cursor = next_new
+ }
+ None => return None
+ }
+ }
+ }
+ if old_cursor != hunk.old_start + hunk.old_len ||
+ new_cursor != hunk.new_start + hunk.new_len {
+ return None
+ }
+ }
+ Some({ changes, additional_alignments: alignments })
+}
+
+///|
+fn specialized_diff(
+ mode : MoonDiffMode,
+ ignore_comments : Bool,
+ original : @model.TextSnapshot,
+ modified : @model.TextSnapshot,
+) -> (@diff.DocumentDiff, MoonDiffMode, Bool)? {
+ let original_lines = snapshot_lines(original)
+ let modified_lines = snapshot_lines(modified)
+ let (document, effective, fell_back) = match mode {
+ Core => return None
+ Token =>
+ (
+ @tokdiff.diff(
+ old=original_lines,
+ new=modified_lines,
+ context=0,
+ line_cleanup=true,
+ ignore_comments~,
+ ),
+ Token,
+ false,
+ )
+ Tree => {
+ let result = @mbtdiff.diff(
+ original.get_value(),
+ modified.get_value(),
+ options=@mbtdiff.DiffOptions::new(context_lines=0, ignore_comments~),
+ )
+ (
+ result.document(),
+ if result.has_fallback() {
+ Token
+ } else {
+ Tree
+ },
+ result.has_fallback(),
+ )
+ }
+ }
+ guard adapt_document(document, original_lines, modified_lines) is Some(diff) else {
+ return None
+ }
+ // Token has no typed failure channel. A differing source cannot silently
+ // become an empty comparison unless ignored rows explain the geometry.
+ if original_lines != modified_lines &&
+ diff.changes.is_empty() &&
+ diff.additional_alignments.is_empty() {
+ if !ignore_comments {
+ return None
+ }
+ let unfiltered = @tokdiff.diff(
+ old=original_lines,
+ new=modified_lines,
+ context=0,
+ line_cleanup=true,
+ ignore_comments=false,
+ )
+ guard adapt_document(unfiltered, original_lines, modified_lines)
+ is Some(unfiltered_diff) &&
+ !unfiltered_diff.changes.is_empty() else {
+ return None
+ }
+ let ignored_alignments = unfiltered_diff.changes.filter_map(change => {
+ if change.original.length() != change.modified.length() {
+ Some(change.line_mapping())
+ } else {
+ None
+ }
+ })
+ let result : @diff.DocumentDiff = {
+ changes: [],
+ additional_alignments: ignored_alignments,
+ }
+ return Some((result, effective, fell_back))
+ }
+ Some((diff, effective, fell_back))
+}
+
+///|
+pub impl @diff.DocumentDiffProvider for MoonDocumentDiffProvider with fn compute_diff(
+ self,
+ original,
+ modified,
+ options,
+) {
+ let requested = self.mode
+ if requested != Core {
+ let core = @diff.CoreDocumentDiffProvider()
+ let strict = core.compute_diff(original, modified, {
+ ignore_trim_whitespace: false,
+ })
+ let lenient = core.compute_diff(original, modified, {
+ ignore_trim_whitespace: true,
+ })
+ if !same_line_change_shape(strict, lenient) ||
+ paired_change_has_edge_whitespace_difference(strict, original, modified) {
+ self.status = { requested, effective: Core, fell_back: true }
+ return if options.ignore_trim_whitespace { lenient } else { strict }
+ }
+ match
+ specialized_diff(requested, self.ignore_comments, original, modified) {
+ Some((result, effective, fell_back)) => {
+ self.status = { requested, effective, fell_back }
+ return result
+ }
+ None => ()
+ }
+ }
+ let result = @diff.CoreDocumentDiffProvider().compute_diff(
+ original, modified, options,
+ )
+ self.status = { requested, effective: Core, fell_back: requested != Core }
+ result
+}
+
+///|
+pub impl @diff.DocumentDiffProvider for MoonDocumentDiffProvider with fn on_did_change(
+ self,
+ listener,
+) {
+ self.did_change.event(_ => listener())
+}
+
+///|
+pub extend MoonDiffMode with Eq::{equal, not_equal}
+
+///|
+pub extend MoonDiffMode with Debug::{to_repr}
+
+///|
+pub extend MoonDiffStatus with Eq::{equal, not_equal}
+
+///|
+pub extend MoonDiffStatus with Debug::{to_repr}
diff --git a/editor/viewer/diff_provider/moondiff/provider_wbtest.mbt b/editor/viewer/diff_provider/moondiff/provider_wbtest.mbt
new file mode 100644
index 000000000..ee0b72f1f
--- /dev/null
+++ b/editor/viewer/diff_provider/moondiff/provider_wbtest.mbt
@@ -0,0 +1,133 @@
+///|
+fn compute_with_provider(
+ provider : MoonDocumentDiffProvider,
+ original : String,
+ modified : String,
+ ignore_trim_whitespace? : Bool = true,
+) -> @diff.DocumentDiff raise {
+ let object : &@diff.DocumentDiffProvider = provider
+ object.compute_diff(TextSnapshot(original), TextSnapshot(modified), {
+ ignore_trim_whitespace,
+ })
+}
+
+///|
+test "Token and Tree honor trim-whitespace options consistently" {
+ for mode in [Token, Tree] {
+ let provider = MoonDocumentDiffProvider(mode~)
+ let ignored = compute_with_provider(
+ provider,
+ " fn answer() { 1 }\t",
+ "fn answer() { 1 }",
+ ignore_trim_whitespace=true,
+ )
+ assert_true(ignored.changes.is_empty())
+ assert_eq(provider.get_status(), {
+ requested: mode,
+ effective: Core,
+ fell_back: true,
+ })
+ let preserved = compute_with_provider(
+ provider,
+ " fn answer() { 1 }\t",
+ "fn answer() { 1 }",
+ ignore_trim_whitespace=false,
+ )
+ assert_false(preserved.changes.is_empty())
+ assert_eq(provider.get_status(), {
+ requested: mode,
+ effective: Core,
+ fell_back: true,
+ })
+ provider.dispose()
+ }
+}
+
+///|
+test "trim-insensitive input keeps the specialized provider" {
+ let provider = MoonDocumentDiffProvider(mode=Token)
+ let result = compute_with_provider(
+ provider,
+ "fn answer() { 1 }",
+ "fn answer() { 2 }",
+ ignore_trim_whitespace=true,
+ )
+ assert_eq(result.changes.length(), 1)
+ assert_eq(provider.get_status(), {
+ requested: Token,
+ effective: Token,
+ fell_back: false,
+ })
+ provider.dispose()
+}
+
+///|
+test "Token provider exposes UTF-16 inner mappings" {
+ let provider = MoonDocumentDiffProvider(mode=Token)
+ let result = compute_with_provider(
+ provider, "fn answer() { \"😀\"; 1 }", "fn answer() { \"😀\"; 2 }",
+ )
+ guard result.changes is [change] else { fail("expected one token change") }
+ guard change.inner_changes is Some([inner]) else {
+ fail("expected one paired inner mapping")
+ }
+ assert_eq(inner.original, Range(1, 21, 1, 22))
+ assert_eq(inner.modified, Range(1, 21, 1, 22))
+ assert_eq(provider.get_status(), {
+ requested: Token,
+ effective: Token,
+ fell_back: false,
+ })
+ provider.dispose()
+}
+
+///|
+test "Tree parse failure reports Token fallback" {
+ let provider = MoonDocumentDiffProvider(mode=Tree)
+ let result = compute_with_provider(provider, "fn broken( {", "fn okay() {}")
+ assert_false(result.changes.is_empty())
+ assert_eq(provider.get_status(), {
+ requested: Tree,
+ effective: Token,
+ fell_back: true,
+ })
+ provider.dispose()
+}
+
+///|
+test "ignored-only rows become geometry-only additional alignments" {
+ let provider = MoonDocumentDiffProvider(mode=Token, ignore_comments=true)
+ let result = compute_with_provider(
+ provider, "fn value() -> Int {\n // obsolete note\n 1\n}", "fn value() -> Int {\n\n // updated note\n 1\n}",
+ )
+ assert_true(result.changes.is_empty())
+ assert_eq(result.additional_alignments.length(), 1)
+ provider.dispose()
+}
+
+///|
+test "equal-length ignored-only edit is a valid empty specialized result" {
+ let provider = MoonDocumentDiffProvider(mode=Token, ignore_comments=true)
+ let result = compute_with_provider(
+ provider, "fn value() {\n // old\n}", "fn value() {\n // new\n}",
+ )
+ assert_true(result.changes.is_empty())
+ assert_true(result.additional_alignments.is_empty())
+ assert_eq(provider.get_status().effective, Token)
+ assert_false(provider.get_status().fell_back)
+ provider.dispose()
+}
+
+///|
+test "provider options notify through the standard provider contract" {
+ let provider = MoonDocumentDiffProvider()
+ let object : &@diff.DocumentDiffProvider = provider
+ let updates = Ref(0)
+ let subscription = object.on_did_change(() => updates.val += 1)
+ provider.set_mode(Token)
+ provider.set_ignore_comments(false)
+ provider.set_ignore_comments(false)
+ assert_eq(updates.val, 2)
+ subscription.dispose()
+ provider.dispose()
+}
diff --git a/editor/viewer/diff_viewer.mbt b/editor/viewer/diff_viewer.mbt
deleted file mode 100644
index 8ca25aacf..000000000
--- a/editor/viewer/diff_viewer.mbt
+++ /dev/null
@@ -1,1192 +0,0 @@
-// Monaco's standalone diff-editor composition: two normal Viewer instances
-// coordinated by line-level diff state. Side-by-side mode shows both children;
-// unified mode keeps the modified child as the interactive editor and projects
-// deleted original lines into it as tokenized ViewZones. The child Viewers
-// deliberately remain ordinary editors so every model-scoped contribution
-// (tokenization, hover, Definition/Peek, selection, feedback) follows the
-// existing Viewer path.
-//
-// Remaining exclusions are explicit: no moved blocks, true-inline single-line
-// rendering, revert actions, unchanged-region collapsing, or overview ruler.
-// Soft wrapping and folding are disabled in the child panes because
-// fixed-height logical lines are the invariant behind both ViewZone layouts.
-
-///|
-/// The presentation selected for one DiffViewer. Both modes retain the same
-/// two models and ordinary child Viewers; changing this value is a rendering
-/// operation, not a model swap.
-pub(all) enum DiffEditorRenderMode {
- SideBySide
- Unified
-} derive(Eq, Debug)
-
-///|
-/// A model-line viewport anchor retained while coordinator-owned ViewZones are
-/// rebuilt. Content-space line tops change between render modes, while this
-/// line's pixel position relative to the viewport must not.
-priv struct DiffViewportAnchor {
- line_number : Int
- viewport_offset : Double
-}
-
-///|
-/// The two caller-owned text models displayed by a DiffViewer. Like Monaco's
-/// `IDiffEditorModel`, the comparison owns neither model and never disposes
-/// them.
-pub(all) struct DiffEditorModel {
- original : @model.TextModel
- modified : @model.TextModel
-}
-
-///|
-pub fn DiffEditorModel::DiffEditorModel(
- original : @model.TextModel,
- modified : @model.TextModel,
-) -> DiffEditorModel {
- { original, modified }
-}
-
-///|
-/// The comparison strategy used by a DiffViewer. `StandardDiff` is the
-/// editor's general-purpose line/character algorithm. The MoonBit-aware modes
-/// are offered only when the modified model's URI ends in `.mbt`.
-pub(all) enum DiffViewerMode {
- StandardDiff
- TokenDiff
- TreeDiff
-} derive(Eq)
-
-///|
-/// A switchable diff surface composed from two ordinary Viewer instances.
-/// The caller owns `host` and must keep it stable and otherwise child-free.
-struct DiffViewer {
- host : @rdom.Element
- root : @rdom.Element
- original_host : @rdom.Element
- modified_host : @rdom.Element
- toolbar : @rdom.Element
- mode_status : @rdom.Element
- mode_buttons : Array[(DiffViewerMode, @rdom.Element)]
- ignore_comments_button : @rdom.Element
- original_viewer : Viewer
- modified_viewer : Viewer
- owned_services : ViewerServices?
- mut render_mode : DiffEditorRenderMode
- mut resize_observer : @base_common.Disposable?
- mut viewport_restore_frame : @base_common.Disposable?
- scroll_disposables : Array[@base_common.Disposable]
- model_disposables : Array[@base_common.Disposable]
- mut original_decoration_ids : Array[String]
- mut modified_decoration_ids : Array[String]
- mut original_zone_ids : Array[String]
- mut modified_zone_ids : Array[String]
- mut diff_mode : DiffViewerMode
- mut ignore_comments : Bool
- mut disposed : Bool
-}
-
-///|
-/// Create an empty comparison in a stable host. An explicit service bundle is
-/// borrowed and shared by both panes; when omitted, one bundle is created and
-/// owned by the DiffViewer. Hosts can keep original-revision semantic requests
-/// inert by giving that model a URI outside their live-document selectors. The
-/// comparison observes its outer host because either child may be constructed
-/// while the caller keeps that host at `display: none`; revealing or resizing
-/// the host then remeasures both ordinary Viewer panes automatically.
-pub fn DiffViewer::create(
- host : @rdom.Element,
- services? : ViewerServices,
- options? : ViewerOptions = ViewerOptions::default(),
- render_mode? : DiffEditorRenderMode = SideBySide,
-) -> DiffViewer {
- let document = @rdom.document()
- let root = document.create_element("section")
- root.set_attribute("class", "moonbit-diff-editor")
- root.set_attribute("role", "region")
- root.set_attribute("aria-label", "File comparison")
- let toolbar = document.create_element("div")
- toolbar.set_attribute("class", "moonbit-diff-editor-toolbar")
- toolbar.set_attribute("role", "toolbar")
- toolbar.set_attribute("aria-label", "Diff mode")
- toolbar.set_attribute("hidden", "")
- let toolbar_label = document.create_element("span")
- toolbar_label.set_attribute("class", "moonbit-diff-editor-toolbar-label")
- toolbar_label.set_inner_html("Diff")
- toolbar.append_child(toolbar_label.as_node())
- let mode_group = document.create_element("div")
- mode_group.set_attribute("class", "moonbit-diff-editor-mode-group")
- mode_group.set_attribute("role", "group")
- mode_group.set_attribute("aria-label", "Comparison algorithm")
- let mode_buttons : Array[(DiffViewerMode, @rdom.Element)] = []
- for mode in [StandardDiff, TokenDiff, TreeDiff] {
- let button = document.create_element("button")
- button.set_attribute("type", "button")
- button.set_attribute("class", "moonbit-diff-editor-mode")
- button.set_attribute("data-diff-mode", mode.attribute_name())
- button.set_attribute("aria-label", mode.accessible_label())
- button.set_attribute("title", mode.title())
- button.set_attribute("aria-pressed", "false")
- button.set_inner_html(mode.visible_label())
- mode_group.append_child(button.as_node())
- mode_buttons.push((mode, button))
- }
- toolbar.append_child(mode_group.as_node())
- let ignore_comments_button = document.create_element("button")
- ignore_comments_button.set_attribute("type", "button")
- ignore_comments_button.set_attribute(
- "class", "moonbit-diff-editor-ignore-comments active",
- )
- ignore_comments_button.set_attribute("aria-label", "Ignore comments")
- ignore_comments_button.set_attribute("aria-pressed", "true")
- ignore_comments_button.set_attribute("disabled", "")
- let ignore_comments_icon = document.create_element("span")
- ignore_comments_icon.set_attribute(
- "class", "moonbit-diff-editor-ignore-comments-icon",
- )
- ignore_comments_icon.set_attribute("aria-hidden", "true")
- ignore_comments_icon.set_inner_html("//")
- ignore_comments_button.append_child(ignore_comments_icon.as_node())
- let ignore_comments_label = document.create_element("span")
- ignore_comments_label.set_attribute(
- "class", "moonbit-diff-editor-ignore-comments-label",
- )
- ignore_comments_label.set_inner_html("Ignore comments")
- ignore_comments_button.append_child(ignore_comments_label.as_node())
- toolbar.append_child(ignore_comments_button.as_node())
- let mode_status = document.create_element("span")
- mode_status.set_attribute("class", "moonbit-diff-editor-mode-status")
- mode_status.set_attribute("aria-live", "polite")
- mode_status.set_attribute("hidden", "")
- toolbar.append_child(mode_status.as_node())
- let panes = document.create_element("div")
- panes.set_attribute("class", "moonbit-diff-editor-panes")
- let original_host = document.create_element("div")
- original_host.set_attribute(
- "class", "moonbit-diff-editor-pane moonbit-diff-editor-original",
- )
- original_host.set_attribute("role", "group")
- original_host.set_attribute("aria-label", "Original file")
- let modified_host = document.create_element("div")
- modified_host.set_attribute(
- "class", "moonbit-diff-editor-pane moonbit-diff-editor-modified",
- )
- modified_host.set_attribute("role", "group")
- modified_host.set_attribute("aria-label", "Modified file")
- panes.append_child(original_host.as_node())
- panes.append_child(modified_host.as_node())
- root.append_child(toolbar.as_node())
- root.append_child(panes.as_node())
- host.append_child(root.as_node())
-
- let (shared_services, owned_services) = match services {
- Some(services) => (services, None)
- None => {
- let services = ViewerServices()
- (services, Some(services))
- }
- }
- // Logical-line alignment currently relies on one fixed line height. Keep
- // the rest of the host's visual/runtime policy intact.
- let pane_options = { ..options, soft_wrap: false, folding: false }
- let original_viewer = create_diff_pane(
- original_host, shared_services, pane_options,
- )
- let modified_viewer = create_diff_pane(
- modified_host, shared_services, pane_options,
- )
- let diff_viewer : DiffViewer = {
- host,
- root,
- original_host,
- modified_host,
- toolbar,
- mode_status,
- mode_buttons,
- ignore_comments_button,
- original_viewer,
- modified_viewer,
- owned_services,
- render_mode,
- resize_observer: None,
- viewport_restore_frame: None,
- scroll_disposables: [],
- model_disposables: [],
- original_decoration_ids: [],
- modified_decoration_ids: [],
- original_zone_ids: [],
- modified_zone_ids: [],
- diff_mode: StandardDiff,
- ignore_comments: true,
- disposed: false,
- }
- diff_viewer.apply_render_mode_dom()
- for entry in diff_viewer.mode_buttons {
- let (mode, button) = entry
- button.add_event_listener("click", event => {
- event.prevent_default()
- event.stop_propagation()
- diff_viewer.set_diff_mode(mode)
- })
- }
- diff_viewer.ignore_comments_button.add_event_listener("click", event => {
- event.prevent_default()
- event.stop_propagation()
- if diff_viewer.diff_mode is StandardDiff {
- return
- }
- diff_viewer.ignore_comments = !diff_viewer.ignore_comments
- diff_viewer.refresh_diff()
- })
- diff_viewer.update_diff_mode_controls(
- specialized_available=false,
- effective_mode=StandardDiff,
- fell_back=false,
- )
- diff_viewer.scroll_disposables.push(
- original_viewer.on_did_scroll_change(event => {
- if diff_viewer.render_mode == SideBySide && event.scroll_top_changed {
- diff_viewer.sync_scroll_top_to(modified_viewer, event.scroll_top)
- }
- if diff_viewer.render_mode == SideBySide && event.scroll_left_changed {
- diff_viewer.sync_scroll_left_to(modified_viewer, event.scroll_left)
- }
- }),
- )
- diff_viewer.scroll_disposables.push(
- modified_viewer.on_did_scroll_change(event => {
- if event.scroll_top_changed {
- diff_viewer.sync_scroll_top_to(original_viewer, event.scroll_top)
- }
- if event.scroll_left_changed {
- diff_viewer.sync_scroll_left_to(original_viewer, event.scroll_left)
- }
- }),
- )
- diff_viewer.resize_observer = @base_browser.observe_element_resize(host, () => {
- diff_viewer.layout()
- })
- diff_viewer
-}
-
-///|
-fn create_diff_pane(
- host : @rdom.Element,
- services : ViewerServices,
- options : ViewerOptions,
-) -> Viewer {
- let viewer = Viewer(
- services~,
- options~,
- initial_container=host,
- force_code_presentation=true,
- )
- viewer.attach(host)
- viewer
-}
-
-///|
-/// Replace or clear the comparison. Reapplying the same models is cheap but
-/// still recomputes presentation so callers can explicitly refresh diff state.
-pub fn DiffViewer::set_model(
- self : DiffViewer,
- diff_model : DiffEditorModel?,
-) -> Unit {
- if self.disposed {
- return
- }
- self.cancel_modified_viewport_restore()
- self.dispose_model_disposables()
- self.clear_diff_artifacts()
- match diff_model {
- None => {
- self.original_viewer.set_model(None)
- self.modified_viewer.set_model(None)
- self.update_diff_mode_controls(
- specialized_available=false,
- effective_mode=StandardDiff,
- fell_back=false,
- )
- }
- Some(diff_model) => {
- self.original_viewer.set_model(Some(diff_model.original))
- self.modified_viewer.set_model(Some(diff_model.modified))
- self.model_disposables.push(
- diff_model.original.on_did_change_content(_ => self.refresh_diff()),
- )
- self.model_disposables.push(
- diff_model.original.on_did_change_tokens(_ => {
- if self.render_mode == Unified {
- self.refresh_diff()
- }
- }),
- )
- self.model_disposables.push(
- diff_model.modified.on_did_change_content(_ => self.refresh_diff()),
- )
- self.refresh_diff()
- self.original_viewer.handle_initialized()
- self.modified_viewer.handle_initialized()
- }
- }
-}
-
-///|
-/// The currently installed pair, or `None` while either pane is empty.
-pub fn DiffViewer::get_model(self : DiffViewer) -> DiffEditorModel? {
- match (self.original_viewer.get_model(), self.modified_viewer.get_model()) {
- (Some(original), Some(modified)) => Some({ original, modified })
- _ => None
- }
-}
-
-///|
-/// The current comparison presentation.
-pub fn DiffViewer::get_render_mode(self : DiffViewer) -> DiffEditorRenderMode {
- self.render_mode
-}
-
-///|
-fn DiffViewer::capture_modified_viewport_anchor(
- self : DiffViewer,
-) -> DiffViewportAnchor? {
- let visible_ranges = self.modified_viewer.get_visible_ranges()
- guard !visible_ranges.is_empty() else { return None }
- let line_number = visible_ranges[0].start_line_number
- let line_top = self.modified_viewer.get_top_for_line_number(line_number)
- let scroll_top = self.modified_viewer.get_scroll_top()
- guard line_top >= 0.0 && scroll_top >= 0.0 else { return None }
- Some({ line_number, viewport_offset: line_top - scroll_top })
-}
-
-///|
-fn DiffViewer::restore_modified_viewport_anchor(
- self : DiffViewer,
- anchor : DiffViewportAnchor?,
-) -> Unit {
- guard anchor is Some(anchor) else { return }
- let line_top = self.modified_viewer.get_top_for_line_number(
- anchor.line_number,
- )
- guard line_top >= 0.0 else { return }
- self.modified_viewer.set_scroll_top(
- (line_top - anchor.viewport_offset).max(0.0),
- )
-}
-
-///|
-fn DiffViewer::cancel_modified_viewport_restore(self : DiffViewer) -> Unit {
- match self.viewport_restore_frame {
- Some(frame) => frame.dispose()
- None => ()
- }
- self.viewport_restore_frame = None
-}
-
-///|
-/// ViewZone mutations enter the ViewModel queue synchronously but reach
-/// ViewLayout during the Viewer's priority-100 render. Restore later in the
-/// same native frame so the browser never paints the displaced numeric
-/// scrollTop. A later model or mode change cancels this anchor.
-fn DiffViewer::schedule_modified_viewport_restore(
- self : DiffViewer,
- anchor : DiffViewportAnchor?,
-) -> Unit {
- guard anchor is Some(anchor) else { return }
- let expected_render_mode = self.render_mode
- self.viewport_restore_frame = Some(
- @base_browser.run_at_this_or_schedule_at_next_animation_frame(
- () => {
- self.viewport_restore_frame = None
- if self.disposed || self.render_mode != expected_render_mode {
- return
- }
- self.restore_modified_viewport_anchor(Some(anchor))
- },
- // Viewer render runs at 100; restoring at 0 observes the committed zone
- // geometry and still runs before the browser paints this native frame.
- priority=0,
- ),
- )
-}
-
-///|
-/// Switch the existing comparison between two panes and one interleaved pane.
-/// Models, services, selections, and the modified Viewer's scroll state stay
-/// installed. Rebuilding only coordinator-owned decorations and ViewZones also
-/// makes repeated calls idempotent.
-pub fn DiffViewer::set_render_mode(
- self : DiffViewer,
- render_mode : DiffEditorRenderMode,
-) -> Unit {
- if self.disposed || self.render_mode == render_mode {
- return
- }
- self.cancel_modified_viewport_restore()
- let viewport_anchor = self.capture_modified_viewport_anchor()
- self.render_mode = render_mode
- self.apply_render_mode_dom()
- self.layout()
- self.refresh_diff()
- self.schedule_modified_viewport_restore(viewport_anchor)
- if render_mode == SideBySide {
- self.sync_scroll_top_to(
- self.original_viewer,
- self.modified_viewer.get_scroll_top(),
- )
- self.sync_scroll_left_to(
- self.original_viewer,
- self.modified_viewer.get_scroll_left(),
- )
- }
-}
-
-///|
-/// Select a comparison strategy. MoonBit-aware modes remain selected while a
-/// non-`.mbt` model is open, but that model is rendered with `StandardDiff`
-/// and the specialized controls stay hidden.
-pub fn DiffViewer::set_diff_mode(
- self : DiffViewer,
- mode : DiffViewerMode,
-) -> Unit {
- if self.disposed || self.diff_mode == mode {
- return
- }
- self.diff_mode = mode
- self.refresh_diff()
-}
-
-///|
-/// The user's selected strategy. The effective renderer can temporarily be
-/// `StandardDiff` for non-`.mbt` files or after a specialized calculation
-/// fails.
-pub fn DiffViewer::get_diff_mode(self : DiffViewer) -> DiffViewerMode {
- self.diff_mode
-}
-
-///|
-/// Clear the comparison while retaining the reusable two-pane surface.
-pub fn DiffViewer::clear(self : DiffViewer) -> Unit {
- self.set_model(None)
-}
-
-///|
-/// Re-measure both child hosts after their caller-controlled layout changes.
-pub fn DiffViewer::layout(self : DiffViewer) -> Unit {
- if !self.disposed {
- self.original_viewer.layout()
- self.modified_viewer.layout()
- }
-}
-
-///|
-/// Release both Viewers and all coordinator-owned state. Caller models and an
-/// explicitly supplied ViewerServices bundle remain caller-owned.
-pub fn DiffViewer::dispose(self : DiffViewer) -> Unit {
- if self.disposed {
- return
- }
- self.cancel_modified_viewport_restore()
- match self.resize_observer {
- Some(observer) => observer.dispose()
- None => ()
- }
- self.resize_observer = None
- self.dispose_model_disposables()
- for disposable in self.scroll_disposables {
- disposable.dispose()
- }
- self.scroll_disposables.clear()
- self.clear_diff_artifacts()
- self.original_viewer.dispose()
- self.modified_viewer.dispose()
- self.host.remove_child(self.root.as_node())
- match self.owned_services {
- Some(services) => services.dispose()
- None => ()
- }
- self.disposed = true
-}
-
-///|
-fn DiffViewer::dispose_model_disposables(self : DiffViewer) -> Unit {
- for disposable in self.model_disposables {
- disposable.dispose()
- }
- self.model_disposables.clear()
-}
-
-///|
-fn DiffViewer::apply_render_mode_dom(self : DiffViewer) -> Unit {
- match self.render_mode {
- SideBySide => {
- self.root.set_attribute(
- "class", "moonbit-diff-editor moonbit-diff-editor-side-by-side",
- )
- self.root.set_attribute("data-render-mode", "side-by-side")
- self.original_host.remove_attribute("aria-hidden")
- self.modified_host.set_attribute("aria-label", "Modified file")
- }
- Unified => {
- self.root.set_attribute(
- "class", "moonbit-diff-editor moonbit-diff-editor-unified",
- )
- self.root.set_attribute("data-render-mode", "unified")
- self.original_host.set_attribute("aria-hidden", "true")
- self.modified_host.set_attribute("aria-label", "Unified diff")
- }
- }
-}
-
-///|
-fn DiffViewer::sync_scroll_top_to(
- self : DiffViewer,
- target : Viewer,
- scroll_top : Double,
-) -> Unit {
- if !self.disposed && (target.get_scroll_top() - scroll_top).abs() > 0.5 {
- target.set_scroll_top(scroll_top)
- }
-}
-
-///|
-fn DiffViewer::sync_scroll_left_to(
- self : DiffViewer,
- target : Viewer,
- scroll_left : Double,
-) -> Unit {
- if !self.disposed && (target.get_scroll_left() - scroll_left).abs() > 0.5 {
- target.set_scroll_left(scroll_left)
- }
-}
-
-///|
-fn DiffViewerMode::attribute_name(self : DiffViewerMode) -> String {
- match self {
- StandardDiff => "standard"
- TokenDiff => "token"
- TreeDiff => "tree"
- }
-}
-
-///|
-fn DiffViewerMode::visible_label(self : DiffViewerMode) -> String {
- match self {
- StandardDiff => "Standard"
- TokenDiff => "Token"
- TreeDiff => "Tree"
- }
-}
-
-///|
-fn DiffViewerMode::accessible_label(self : DiffViewerMode) -> String {
- match self {
- StandardDiff => "Standard diff"
- TokenDiff => "Token diff"
- TreeDiff => "Tree diff"
- }
-}
-
-///|
-fn DiffViewerMode::title(self : DiffViewerMode) -> String {
- match self {
- StandardDiff => "Use the standard line and character diff"
- TokenDiff => "Compare MoonBit tokens with moondiff"
- TreeDiff => "Compare MoonBit syntax trees with moondiff"
- }
-}
-
-///|
-fn DiffViewer::update_diff_mode_controls(
- self : DiffViewer,
- specialized_available~ : Bool,
- effective_mode~ : DiffViewerMode,
- fell_back~ : Bool,
-) -> Unit {
- let selected_mode = if specialized_available {
- self.diff_mode
- } else {
- StandardDiff
- }
- if specialized_available {
- self.toolbar.remove_attribute("hidden")
- self.root.set_attribute("data-moondiff-available", "true")
- } else {
- self.toolbar.set_attribute("hidden", "")
- self.root.remove_attribute("data-moondiff-available")
- }
- self.root.set_attribute("data-diff-mode", selected_mode.attribute_name())
- self.root.set_attribute("data-diff-renderer", effective_mode.attribute_name())
- self.root.set_attribute(
- "data-ignore-comments",
- self.ignore_comments.to_string(),
- )
- for entry in self.mode_buttons {
- let (mode, button) = entry
- let active = mode == selected_mode
- button.set_attribute(
- "class",
- if active {
- "moonbit-diff-editor-mode active"
- } else {
- "moonbit-diff-editor-mode"
- },
- )
- button.set_attribute("aria-pressed", active.to_string())
- }
- self.ignore_comments_button.set_attribute(
- "class",
- if self.ignore_comments {
- "moonbit-diff-editor-ignore-comments active"
- } else {
- "moonbit-diff-editor-ignore-comments"
- },
- )
- self.ignore_comments_button.set_attribute(
- "aria-pressed",
- self.ignore_comments.to_string(),
- )
- if specialized_available && !(self.diff_mode is StandardDiff) {
- self.ignore_comments_button.remove_attribute("disabled")
- self.ignore_comments_button.set_attribute(
- "title",
- if self.ignore_comments {
- "Show comment and blank-line changes"
- } else {
- "Ignore MoonBit comment and blank-line changes"
- },
- )
- } else {
- self.ignore_comments_button.set_attribute("disabled", "")
- self.ignore_comments_button.set_attribute(
- "title", "Available in Token and Tree diff",
- )
- }
- if fell_back {
- self.root.set_attribute("data-diff-fallback", "true")
- self.mode_status.remove_attribute("hidden")
- let message = match (self.diff_mode, effective_mode) {
- (TokenDiff, StandardDiff) =>
- "Token diff unavailable — showing standard diff"
- (TreeDiff, TokenDiff) => "Tree diff unavailable — showing token diff"
- (TreeDiff, StandardDiff) =>
- "Tree diff unavailable — showing standard diff"
- _ => ""
- }
- self.mode_status.set_attribute("title", message)
- self.mode_status.set_inner_html(message)
- } else {
- self.root.remove_attribute("data-diff-fallback")
- self.mode_status.set_attribute("hidden", "")
- self.mode_status.remove_attribute("title")
- self.mode_status.set_inner_html("")
- }
-}
-
-///|
-fn DiffViewer::refresh_diff(self : DiffViewer) -> Unit {
- if self.disposed {
- return
- }
- guard self.get_model() is Some(diff_model) else { return }
- self.clear_diff_artifacts()
- let original_lines = diff_model.original.get_lines_content()
- let modified_lines = diff_model.modified.get_lines_content()
- let specialized_available = diff_model.supports_moondiff()
- let calculation = calculate_diff_changes(
- self.diff_mode,
- specialized_available,
- self.ignore_comments,
- original_lines,
- modified_lines,
- diff_model.original.get_value(),
- diff_model.modified.get_value(),
- )
- self.update_diff_mode_controls(
- specialized_available~,
- effective_mode=calculation.effective_mode,
- fell_back=calculation.fell_back,
- )
- let changes = calculation.changes
- let presentation = diff_presentation(changes)
- let modified_decorations = line_decorations(
- diff_model.modified,
- presentation.modified_changed,
- "diff-editor-line-insert",
- "diff-editor-gutter-insert",
- "diff-editor-modified-line",
- )
- modified_decorations.append(
- inline_decorations(
- presentation.modified_inner_changed,
- "diff-editor-char-insert",
- "diff-editor-modified-character",
- ),
- )
- self.modified_decoration_ids = self.modified_viewer.delta_decorations(
- [],
- modified_decorations,
- )
- match self.render_mode {
- SideBySide => {
- let original_decorations = line_decorations(
- diff_model.original,
- presentation.original_changed,
- "diff-editor-line-delete",
- "diff-editor-gutter-delete",
- "diff-editor-original-line",
- )
- original_decorations.append(
- inline_decorations(
- presentation.original_inner_changed,
- "diff-editor-char-delete",
- "diff-editor-original-character",
- ),
- )
- self.original_decoration_ids = self.original_viewer.delta_decorations(
- [],
- original_decorations,
- )
- self.original_zone_ids = install_alignment_zones(
- self.original_viewer,
- presentation.original_spacers,
- )
- self.modified_zone_ids = install_alignment_zones(
- self.modified_viewer,
- presentation.modified_spacers,
- )
- }
- Unified =>
- self.modified_zone_ids = install_unified_deleted_zones(
- self.modified_viewer,
- diff_model.original,
- changes,
- )
- }
-}
-
-///|
-fn DiffViewer::clear_diff_artifacts(self : DiffViewer) -> Unit {
- self.original_decoration_ids = self.original_viewer.delta_decorations(
- self.original_decoration_ids,
- [],
- )
- self.modified_decoration_ids = self.modified_viewer.delta_decorations(
- self.modified_decoration_ids,
- [],
- )
- remove_diff_zones(self.original_viewer, self.original_zone_ids)
- remove_diff_zones(self.modified_viewer, self.modified_zone_ids)
- self.original_zone_ids = []
- self.modified_zone_ids = []
-}
-
-///|
-priv struct DiffSpacer {
- after_line_number : Int
- height_in_lines : Int
-}
-
-///|
-priv struct DiffLineChange {
- original : @base_common.LineRange
- modified : @base_common.LineRange
- original_inner_changed : Array[@base_common.Range]
- modified_inner_changed : Array[@base_common.Range]
- render_change : Bool
-}
-
-///|
-priv struct DiffPresentation {
- original_changed : Array[@base_common.LineRange]
- modified_changed : Array[@base_common.LineRange]
- original_inner_changed : Array[@base_common.Range]
- modified_inner_changed : Array[@base_common.Range]
- original_spacers : Array[DiffSpacer]
- modified_spacers : Array[DiffSpacer]
-}
-
-///|
-/// Convert line mappings once into the four independent rendering inputs.
-/// Unequal replacement ranges align from their starts and put the remaining
-/// height after the shorter block; insertions/deletions therefore use the
-/// preceding logical line (including Monaco's valid line-zero anchor).
-fn diff_presentation(changes : Array[DiffLineChange]) -> DiffPresentation {
- let original_changed = []
- let modified_changed = []
- let original_inner_changed = []
- let modified_inner_changed = []
- let original_spacers = []
- let modified_spacers = []
- for change in changes {
- if change.render_change {
- if !change.original.is_empty() {
- original_changed.push(change.original)
- }
- if !change.modified.is_empty() {
- modified_changed.push(change.modified)
- }
- for original in change.original_inner_changed {
- if !original.is_empty() {
- original_inner_changed.push(original)
- }
- }
- for modified in change.modified_inner_changed {
- if !modified.is_empty() {
- modified_inner_changed.push(modified)
- }
- }
- }
- let original_length = change.original.length()
- let modified_length = change.modified.length()
- if original_length < modified_length {
- original_spacers.push({
- after_line_number: change.original.end_line_number_exclusive - 1,
- height_in_lines: modified_length - original_length,
- })
- } else if modified_length < original_length {
- modified_spacers.push({
- after_line_number: change.modified.end_line_number_exclusive - 1,
- height_in_lines: original_length - modified_length,
- })
- }
- }
- {
- original_changed,
- modified_changed,
- original_inner_changed,
- modified_inner_changed,
- original_spacers,
- modified_spacers,
- }
-}
-
-///|
-fn line_decorations(
- model : @model.TextModel,
- ranges : Array[@base_common.LineRange],
- line_class : String,
- gutter_class : String,
- description : String,
-) -> Array[@model.ModelDeltaDecoration] {
- // An empty document has one model line for cursor geometry, but it is not a
- // deleted/inserted source line. Its physical line still contributes to
- // spacer height, so only the decoration is suppressed.
- if model.get_value().is_empty() {
- return []
- }
- let decorations : Array[@model.ModelDeltaDecoration] = []
- for range in ranges {
- guard !range.is_empty() else { continue }
- let end_line = range.end_line_number_exclusive - 1
- decorations.push({
- range: Range(
- range.start_line_number,
- 1,
- end_line,
- model.get_line_max_column(end_line),
- ),
- options: ModelDecorationOptions(
- line_class,
- is_whole_line=true,
- lines_decorations_class_name=gutter_class,
- description~,
- ),
- })
- }
- decorations
-}
-
-///|
-fn inline_decorations(
- ranges : Array[@base_common.Range],
- inline_class : String,
- description : String,
-) -> Array[@model.ModelDeltaDecoration] {
- ranges.map(range => {
- range,
- options: ModelDecorationOptions(
- "",
- inline_class_name=inline_class,
- description~,
- ),
- })
-}
-
-///|
-fn install_alignment_zones(
- viewer : Viewer,
- spacers : Array[DiffSpacer],
-) -> Array[String] {
- let ids : Array[String] = []
- if spacers.is_empty() {
- return ids
- }
- viewer.change_view_zones(accessor => {
- for spacer in spacers {
- let dom_node = @rdom.document().create_element("div")
- dom_node.set_attribute("class", "diff-editor-alignment-zone")
- ids.push(
- accessor.add_zone(
- view_zone(
- spacer.after_line_number,
- dom_node,
- suppress_mouse_down=true,
- height_in_lines=spacer.height_in_lines.to_double(),
- ),
- ),
- )
- }
- })
- ids
-}
-
-///|
-priv struct DiffInlineInterval {
- start_offset : Int
- end_offset : Int
-}
-
-///|
-fn deleted_inline_intervals_for_line(
- ranges : Array[@base_common.Range],
- line_number : Int,
- line_length : Int,
-) -> Array[DiffInlineInterval] {
- let intervals = []
- for range in ranges {
- if line_number < range.start_line_number ||
- line_number > range.end_line_number {
- continue
- }
- let start_offset = (if line_number == range.start_line_number {
- range.start_column - 1
- } else {
- 0
- })
- .max(0)
- .min(line_length)
- let end_offset = (if line_number == range.end_line_number {
- range.end_column - 1
- } else {
- line_length
- })
- .max(0)
- .min(line_length)
- if start_offset < end_offset {
- intervals.push({ start_offset, end_offset })
- }
- }
- intervals
-}
-
-///|
-fn append_deleted_line_tokens(
- row : @rdom.Element,
- model : @model.TextModel,
- line_number : Int,
- inner_ranges : Array[@base_common.Range],
-) -> Unit {
- let document = @rdom.document()
- let text = model.get_line_content(line_number)
- if text.is_empty() {
- row.set_attribute("data-empty-line", "true")
- return
- }
- let intervals = deleted_inline_intervals_for_line(
- inner_ranges,
- line_number,
- text.length(),
- )
- let tokens = model.tokenization.get_line_tokens(line_number).inflate()
- let token_count = tokens.get_count()
- let mut token_index = 0
- let mut offset = 0
- while offset < text.length() {
- while token_index + 1 < token_count &&
- tokens.get_end_offset(token_index) <= offset {
- token_index += 1
- }
- let mut next_offset = if token_count > 0 &&
- tokens.get_end_offset(token_index) > offset {
- tokens.get_end_offset(token_index).min(text.length())
- } else {
- text.length()
- }
- let mut changed = false
- for interval in intervals {
- if interval.start_offset <= offset && offset < interval.end_offset {
- changed = true
- }
- if interval.start_offset > offset {
- next_offset = next_offset.min(interval.start_offset)
- }
- if interval.end_offset > offset {
- next_offset = next_offset.min(interval.end_offset)
- }
- }
- let span = document.create_element("span")
- let token_class = if token_count > 0 {
- tokens.get_class_name(token_index)
- } else {
- "mtk1"
- }
- span.set_attribute(
- "class",
- if changed {
- token_class + " diff-editor-char-delete"
- } else {
- token_class
- },
- )
- span.append_child(
- document
- .create_text_node(text.unsafe_substring(start=offset, end=next_offset))
- .as_node(),
- )
- row.append_child(span.as_node())
- offset = next_offset
- }
-}
-
-///|
-fn original_inner_ranges(change : DiffLineChange) -> Array[@base_common.Range] {
- let ranges = []
- for original in change.original_inner_changed {
- if !original.is_empty() {
- ranges.push(original)
- }
- }
- ranges
-}
-
-///|
-fn unified_deleted_zone_anchor(change : DiffLineChange) -> Int {
- change.modified.start_line_number - 1
-}
-
-///|
-fn deleted_line_visual_columns(text : String, tab_size : Int) -> Int {
- @core.visible_column_from_column(text, text.length() + 1, tab_size.max(1))
-}
-
-///|
-fn deleted_block_min_width(
- model : @model.TextModel,
- range : @base_common.LineRange,
- tab_size : Int,
- typical_halfwidth_character_width : Double,
-) -> Double {
- let mut longest_visual_columns = 0
- for line_number in range.start_line_number.. Array[String] {
- let ids : Array[String] = []
- let configuration = viewer.configuration.snapshot()
- let options = configuration.options
- viewer.change_view_zones(accessor => {
- for ordinal, change in changes {
- guard change.render_change && !change.original.is_empty() else {
- continue
- }
- let document = @rdom.document()
- let content = document.create_element("div")
- content.set_attribute("class", "diff-editor-unified-deleted-block")
- content.set_attribute("role", "group")
- content.set_attribute(
- "aria-label",
- "Deleted lines \{change.original.start_line_number} through \{change.original.end_line_number_exclusive - 1}",
- )
- content.set_attribute(
- "data-original-start-line",
- change.original.start_line_number.to_string(),
- )
- content.set_attribute(
- "data-original-end-line",
- (change.original.end_line_number_exclusive - 1).to_string(),
- )
- @base_browser.set_style_property(
- content,
- "tab-size",
- options.tab_size.to_string(),
- )
- let margin = document.create_element("div")
- margin.set_attribute("class", "diff-editor-unified-deleted-margin")
- let inner_ranges = original_inner_ranges(change)
- for
- line_number in change.original.start_line_number.. Unit {
- if ids.is_empty() {
- return
- }
- viewer.change_view_zones(accessor => {
- for id in ids {
- accessor.remove_zone(id)
- }
- })
-}
-
-///|
-pub extend DiffEditorRenderMode with Debug::{to_repr}
-
-///|
-pub extend DiffEditorRenderMode with Eq::{equal, not_equal}
-
-///|
-pub extend DiffViewerMode with Eq::{equal, not_equal}
diff --git a/editor/viewer/diff_viewer_moondiff.mbt b/editor/viewer/diff_viewer_moondiff.mbt
deleted file mode 100644
index fe85fdae3..000000000
--- a/editor/viewer/diff_viewer_moondiff.mbt
+++ /dev/null
@@ -1,375 +0,0 @@
-///|
-/// The selected algorithm plus the line mappings consumed by the existing
-/// decoration/alignment renderer. `effective_mode` records the algorithm that
-/// produced those mappings when moondiff degrades Tree to Token or validation
-/// rejects a specialized document and returns to Standard.
-priv struct DiffChangeCalculation {
- changes : Array[DiffLineChange]
- effective_mode : DiffViewerMode
- fell_back : Bool
-}
-
-///|
-fn DiffEditorModel::supports_moondiff(self : DiffEditorModel) -> Bool {
- self.modified.uri.path.to_lower().has_suffix(".mbt")
-}
-
-///|
-fn standard_diff_changes(
- original_lines : Array[String],
- modified_lines : Array[String],
-) -> Array[DiffLineChange] {
- let diff = @diff.get_default().compute_diff(original_lines, modified_lines, {
- ignore_trim_whitespace: false,
- max_computation_time_ms: 5000,
- compute_moves: false,
- })
- diff.changes.map(change => {
- let original_inner_changed : Array[@base_common.Range] = []
- let modified_inner_changed : Array[@base_common.Range] = []
- match change.get_inner_changes() {
- Some(inner_changes) =>
- for inner_change in inner_changes {
- let (original, modified) = inner_change
- original_inner_changed.push(original)
- modified_inner_changed.push(modified)
- }
- None => ()
- }
- {
- original: change.original,
- modified: change.modified,
- original_inner_changed,
- modified_inner_changed,
- render_change: true,
- }
- })
-}
-
-///|
-fn calculate_diff_changes(
- requested_mode : DiffViewerMode,
- specialized_available : Bool,
- ignore_comments : Bool,
- original_lines : Array[String],
- modified_lines : Array[String],
- original_source : String,
- modified_source : String,
-) -> DiffChangeCalculation {
- if !specialized_available || requested_mode is StandardDiff {
- return {
- changes: standard_diff_changes(original_lines, modified_lines),
- effective_mode: StandardDiff,
- fell_back: false,
- }
- }
- match
- moondiff_changes(
- requested_mode, ignore_comments, original_lines, modified_lines, original_source,
- modified_source,
- ) {
- Some(calculation) => calculation
- None =>
- {
- changes: standard_diff_changes(original_lines, modified_lines),
- effective_mode: StandardDiff,
- fell_back: true,
- }
- }
-}
-
-///|
-fn moondiff_changes(
- mode : DiffViewerMode,
- ignore_comments : Bool,
- original_lines : Array[String],
- modified_lines : Array[String],
- original_source : String,
- modified_source : String,
-) -> DiffChangeCalculation? {
- let (document, effective_mode, fell_back) = match mode {
- StandardDiff => return None
- TokenDiff =>
- (
- @tokdiff.diff(
- old=original_lines,
- new=modified_lines,
- context=0,
- line_cleanup=true,
- ignore_comments~,
- ),
- TokenDiff,
- false,
- )
- TreeDiff => {
- let result = @mbtdiff.diff(
- original_source,
- modified_source,
- options=@mbtdiff.DiffOptions::new(context_lines=0, ignore_comments~),
- )
- let fell_back = result.has_fallback()
- // A typed Tree fallback already contains moondiff's lexical document.
- // Keep that result instead of calculating an unrelated Standard diff.
- (
- result.document(),
- if fell_back {
- TokenDiff
- } else {
- TreeDiff
- },
- fell_back,
- )
- }
- }
- guard moondiff_document_changes(document, original_lines, modified_lines)
- is Some(changes) else {
- return None
- }
- // Token diff has no typed error channel, so validate its empty filtered
- // document against the unfiltered calculation. For either specialized mode,
- // keep validated one-sided rows as neutral alignment changes: comments and
- // blank lines remain undecorated, while later source lines still line up.
- if original_source != modified_source && changes.is_empty() {
- if ignore_comments {
- match ignored_only_alignment_changes(original_lines, modified_lines) {
- Some(alignment) =>
- return Some({ changes: alignment, effective_mode, fell_back })
- None if effective_mode is TokenDiff => return None
- None => ()
- }
- } else if effective_mode is TokenDiff {
- return None
- }
- }
- Some({ changes, effective_mode, fell_back })
-}
-
-///|
-fn ignored_only_alignment_changes(
- original_lines : Array[String],
- modified_lines : Array[String],
-) -> Array[DiffLineChange]? {
- let unfiltered = @tokdiff.diff(
- old=original_lines,
- new=modified_lines,
- context=0,
- line_cleanup=true,
- ignore_comments=false,
- )
- match moondiff_document_changes(unfiltered, original_lines, modified_lines) {
- Some(changes) if !changes.is_empty() => {
- let alignment = []
- for change in changes {
- if change.original.length() != change.modified.length() {
- alignment.push({
- original: change.original,
- modified: change.modified,
- original_inner_changed: [],
- modified_inner_changed: [],
- render_change: false,
- })
- }
- }
- Some(alignment)
- }
- _ => None
- }
-}
-
-///|
-fn valid_moondiff_hunk_range(start : Int, len : Int, total : Int) -> Bool {
- start >= 0 && len >= 0 && start <= total && len <= total - start
-}
-
-///|
-fn valid_moondiff_line(
- line : @tokdiff.DiffLine,
- expected_index : Int,
- source : Array[String],
-) -> Bool {
- line.index == expected_index &&
- expected_index >= 0 &&
- expected_index < source.length() &&
- line.text() == source[expected_index]
-}
-
-///|
-fn moondiff_inner_ranges(line : @tokdiff.DiffLine) -> Array[@base_common.Range] {
- let ranges : Array[@base_common.Range] = []
- let mut column = 1
- for segment in line.segments {
- let end_column = column + segment.text.length()
- if segment.kind is Changed && end_column > column {
- ranges.push(Range(line.index + 1, column, line.index + 1, end_column))
- }
- column = end_column
- }
- ranges
-}
-
-///|
-fn moondiff_paired_change(
- original : @tokdiff.DiffLine,
- modified : @tokdiff.DiffLine,
-) -> DiffLineChange {
- {
- original: LineRange(original.index + 1, original.index + 2),
- modified: LineRange(modified.index + 1, modified.index + 2),
- original_inner_changed: moondiff_inner_ranges(original),
- modified_inner_changed: moondiff_inner_ranges(modified),
- render_change: true,
- }
-}
-
-///|
-fn moondiff_old_only_change(
- original : @tokdiff.DiffLine,
- modified_cursor : Int,
- render_change : Bool,
-) -> DiffLineChange {
- {
- original: LineRange(original.index + 1, original.index + 2),
- modified: LineRange(modified_cursor + 1, modified_cursor + 1),
- original_inner_changed: moondiff_inner_ranges(original),
- modified_inner_changed: [],
- render_change,
- }
-}
-
-///|
-fn moondiff_new_only_change(
- modified : @tokdiff.DiffLine,
- original_cursor : Int,
- render_change : Bool,
-) -> DiffLineChange {
- {
- original: LineRange(original_cursor + 1, original_cursor + 1),
- modified: LineRange(modified.index + 1, modified.index + 2),
- original_inner_changed: [],
- modified_inner_changed: moondiff_inner_ranges(modified),
- render_change,
- }
-}
-
-///|
-fn append_moondiff_rows(
- rows : Array[@tokdiff.DiffRow],
- render_changes : Bool,
- original_lines : Array[String],
- modified_lines : Array[String],
- original_cursor : Int,
- modified_cursor : Int,
- changes : Array[DiffLineChange],
-) -> (Int, Int)? {
- let mut old_cursor = original_cursor
- let mut new_cursor = modified_cursor
- for row in rows {
- match row {
- Paired(original, modified) => {
- if !valid_moondiff_line(original, old_cursor, original_lines) ||
- !valid_moondiff_line(modified, new_cursor, modified_lines) {
- return None
- }
- if render_changes {
- changes.push(moondiff_paired_change(original, modified))
- }
- old_cursor += 1
- new_cursor += 1
- }
- OldOnly(original) => {
- if !valid_moondiff_line(original, old_cursor, original_lines) {
- return None
- }
- changes.push(
- moondiff_old_only_change(original, new_cursor, render_changes),
- )
- old_cursor += 1
- }
- NewOnly(modified) => {
- if !valid_moondiff_line(modified, new_cursor, modified_lines) {
- return None
- }
- changes.push(
- moondiff_new_only_change(modified, old_cursor, render_changes),
- )
- new_cursor += 1
- }
- }
- }
- Some((old_cursor, new_cursor))
-}
-
-///|
-/// Validate moondiff's renderer-neutral document against the exact models and
-/// adapt its zero-based aligned rows to the Viewer's one-based line/range
-/// contract. Validation is also Token mode's failure boundary because its
-/// public calculation API is deliberately total and has no typed error value.
-fn moondiff_document_changes(
- document : @tokdiff.DiffDocument,
- original_lines : Array[String],
- modified_lines : Array[String],
-) -> Array[DiffLineChange]? {
- let changes : Array[DiffLineChange] = []
- for hunk in document.hunks {
- if !valid_moondiff_hunk_range(
- hunk.old_start,
- hunk.old_len,
- original_lines.length(),
- ) ||
- !valid_moondiff_hunk_range(
- hunk.new_start,
- hunk.new_len,
- modified_lines.length(),
- ) {
- return None
- }
- let mut old_cursor = hunk.old_start
- let mut new_cursor = hunk.new_start
- for block in hunk.blocks {
- match block {
- ContextBlock(lines) =>
- for line in lines {
- if line.old_index != old_cursor ||
- line.new_index != new_cursor ||
- old_cursor >= original_lines.length() ||
- new_cursor >= modified_lines.length() ||
- line.text != original_lines[old_cursor] ||
- line.text != modified_lines[new_cursor] {
- return None
- }
- old_cursor += 1
- new_cursor += 1
- }
- ChangeBlock(rows) =>
- match
- append_moondiff_rows(
- rows, true, original_lines, modified_lines, old_cursor, new_cursor,
- changes,
- ) {
- Some((next_old, next_new)) => {
- old_cursor = next_old
- new_cursor = next_new
- }
- None => return None
- }
- IgnoredBlock(rows) =>
- match
- append_moondiff_rows(
- rows, false, original_lines, modified_lines, old_cursor, new_cursor,
- changes,
- ) {
- Some((next_old, next_new)) => {
- old_cursor = next_old
- new_cursor = next_new
- }
- None => return None
- }
- }
- }
- if old_cursor != hunk.old_start + hunk.old_len ||
- new_cursor != hunk.new_start + hunk.new_len {
- return None
- }
- }
- Some(changes)
-}
diff --git a/editor/viewer/diff_viewer_wbtest.mbt b/editor/viewer/diff_viewer_wbtest.mbt
deleted file mode 100644
index fb6b5b9a1..000000000
--- a/editor/viewer/diff_viewer_wbtest.mbt
+++ /dev/null
@@ -1,391 +0,0 @@
-///|
-test "diff presentation aligns insertions, deletions, and replacements" {
- let changes : Array[DiffLineChange] = [
- {
- original: LineRange(3, 3),
- modified: LineRange(3, 5),
- original_inner_changed: [],
- modified_inner_changed: [],
- render_change: true,
- },
- {
- original: LineRange(8, 11),
- modified: LineRange(10, 10),
- original_inner_changed: [],
- modified_inner_changed: [],
- render_change: true,
- },
- {
- original: LineRange(14, 16),
- modified: LineRange(13, 17),
- original_inner_changed: [],
- modified_inner_changed: [],
- render_change: true,
- },
- ]
- let presentation = diff_presentation(changes)
- assert_eq(presentation.original_changed, [LineRange(8, 11), LineRange(14, 16)])
- assert_eq(presentation.modified_changed, [LineRange(3, 5), LineRange(13, 17)])
- assert_eq(presentation.original_spacers.length(), 2)
- assert_eq(presentation.original_spacers[0].after_line_number, 2)
- assert_eq(presentation.original_spacers[0].height_in_lines, 2)
- assert_eq(presentation.original_spacers[1].after_line_number, 15)
- assert_eq(presentation.original_spacers[1].height_in_lines, 2)
- assert_eq(presentation.modified_spacers.length(), 1)
- assert_eq(presentation.modified_spacers[0].after_line_number, 9)
- assert_eq(presentation.modified_spacers[0].height_in_lines, 3)
-}
-
-///|
-test "diff presentation supports a spacer before the first line" {
- let changes : Array[DiffLineChange] = [
- {
- original: LineRange(1, 1),
- modified: LineRange(1, 3),
- original_inner_changed: [],
- modified_inner_changed: [],
- render_change: true,
- },
- ]
- let presentation = diff_presentation(changes)
- assert_eq(presentation.original_spacers.length(), 1)
- assert_eq(presentation.original_spacers[0].after_line_number, 0)
- assert_eq(presentation.original_spacers[0].height_in_lines, 2)
-}
-
-///|
-test "diff presentation separates non-empty character ranges by side" {
- let original = @base_common.Range(3, 5, 3, 8)
- let modified = @base_common.Range(3, 5, 3, 9)
- let inserted = @base_common.Range(4, 1, 4, 3)
- let presentation = diff_presentation([
- {
- original: LineRange(3, 5),
- modified: LineRange(3, 5),
- original_inner_changed: [original, Range(4, 1, 4, 1)],
- modified_inner_changed: [modified, inserted],
- render_change: true,
- },
- ])
- assert_eq(presentation.original_inner_changed, [original])
- assert_eq(presentation.modified_inner_changed, [modified, inserted])
-}
-
-///|
-test "unified deleted lines keep UTF-16 character intervals per row" {
- let ranges = [@base_common.Range(2, 3, 2, 6), Range(3, 5, 5, 3)]
- let first = deleted_inline_intervals_for_line(ranges, 2, 8)
- assert_eq(first.length(), 1)
- assert_eq(first[0].start_offset, 2)
- assert_eq(first[0].end_offset, 5)
- let middle = deleted_inline_intervals_for_line(ranges, 4, 7)
- assert_eq(middle.length(), 1)
- assert_eq(middle[0].start_offset, 0)
- assert_eq(middle[0].end_offset, 7)
- let ending = deleted_inline_intervals_for_line(ranges, 5, 6)
- assert_eq(ending.length(), 1)
- assert_eq(ending[0].start_offset, 0)
- assert_eq(ending[0].end_offset, 2)
-}
-
-///|
-test "unified deleted zones precede replacement and deletion anchors" {
- assert_eq(
- unified_deleted_zone_anchor({
- original: LineRange(4, 6),
- modified: LineRange(4, 5),
- original_inner_changed: [],
- modified_inner_changed: [],
- render_change: true,
- }),
- 3,
- )
- assert_eq(
- unified_deleted_zone_anchor({
- original: LineRange(1, 3),
- modified: LineRange(1, 1),
- original_inner_changed: [],
- modified_inner_changed: [],
- render_change: true,
- }),
- 0,
- )
-}
-
-///|
-test "unified deleted width follows tab stops and wide characters" {
- assert_eq(deleted_line_visual_columns("\t", 4), 4)
- assert_eq(deleted_line_visual_columns("a\tb", 4), 5)
- assert_eq(deleted_line_visual_columns("中🙂", 4), 4)
-}
-
-///|
-test "token diff adapts moondiff token segments to Viewer ranges" {
- let original = "fn answer() { 1 }"
- let modified = "fn answer() { 2 }"
- let calculation = calculate_diff_changes(
- TokenDiff,
- true,
- false,
- [original],
- [modified],
- original,
- modified,
- )
- assert_true(calculation.effective_mode is TokenDiff)
- assert_false(calculation.fell_back)
- guard calculation.changes is [change] else {
- fail("expected one aligned token change")
- }
- assert_eq(change.original, LineRange(1, 2))
- assert_eq(change.modified, LineRange(1, 2))
- assert_eq(change.original_inner_changed, [Range(1, 15, 1, 16)])
- assert_eq(change.modified_inner_changed, [Range(1, 15, 1, 16)])
-}
-
-///|
-test "token diff ranges remain UTF-16 after a surrogate pair" {
- let original = "fn answer() { \"😀\"; 1 }"
- let modified = "fn answer() { \"😀\"; 2 }"
- let calculation = calculate_diff_changes(
- TokenDiff,
- true,
- false,
- [original],
- [modified],
- original,
- modified,
- )
- guard calculation.changes is [change] else {
- fail("expected one aligned token change")
- }
- assert_eq(change.original_inner_changed, [Range(1, 21, 1, 22)])
- assert_eq(change.modified_inner_changed, [Range(1, 21, 1, 22)])
-}
-
-///|
-test "tree diff uses structural moondiff output without fallback" {
- let original = "fn answer() { old_value() }"
- let modified = "fn answer() { new_value() }"
- let calculation = calculate_diff_changes(
- TreeDiff,
- true,
- false,
- [original],
- [modified],
- original,
- modified,
- )
- assert_true(calculation.effective_mode is TreeDiff)
- assert_false(calculation.fell_back)
- assert_false(calculation.changes.is_empty())
- let presentation = diff_presentation(calculation.changes)
- assert_eq(presentation.original_changed, [LineRange(1, 2)])
- assert_eq(presentation.modified_changed, [LineRange(1, 2)])
- assert_false(presentation.original_inner_changed.is_empty())
- assert_false(presentation.modified_inner_changed.is_empty())
-}
-
-///|
-test "tree parse failure keeps moondiff's token fallback" {
- let original = "fn broken( {"
- let modified = "fn okay() {}"
- let calculation = calculate_diff_changes(
- TreeDiff,
- true,
- false,
- [original],
- [modified],
- original,
- modified,
- )
- assert_true(calculation.effective_mode is TokenDiff)
- assert_true(calculation.fell_back)
- assert_false(calculation.changes.is_empty())
-}
-
-///|
-test "token and tree diff accept ignored-only source pairs" {
- let original_lines = ["fn value() -> Int {", " // obsolete note", " 1", "}"]
- let modified_lines = [
- "fn value() -> Int {", "", " // updated note", " 1", "}",
- ]
- let original = original_lines.join("\n")
- let modified = modified_lines.join("\n")
- for mode in [TokenDiff, TreeDiff] {
- let calculation = calculate_diff_changes(
- mode, true, true, original_lines, modified_lines, original, modified,
- )
- assert_true(calculation.effective_mode == mode)
- assert_false(calculation.fell_back)
- assert_eq(calculation.changes.length(), 1)
- assert_false(calculation.changes[0].render_change)
- let presentation = diff_presentation(calculation.changes)
- assert_true(presentation.original_changed.is_empty())
- assert_true(presentation.modified_changed.is_empty())
- assert_eq(presentation.original_spacers.length(), 1)
- assert_eq(presentation.original_spacers[0].after_line_number, 1)
- assert_eq(presentation.original_spacers[0].height_in_lines, 1)
- }
- let visible = calculate_diff_changes(
- TokenDiff,
- true,
- false,
- original_lines,
- modified_lines,
- original,
- modified,
- )
- assert_false(visible.changes.is_empty())
-}
-
-///|
-test "equal-length ignored-only pairs remain valid empty results" {
- let original_lines = ["fn value() -> Int {", " // obsolete note", " 1", "}"]
- let modified_lines = ["fn value() -> Int {", " // updated note", " 1", "}"]
- let original = original_lines.join("\n")
- let modified = modified_lines.join("\n")
- for mode in [TokenDiff, TreeDiff] {
- let calculation = calculate_diff_changes(
- mode, true, true, original_lines, modified_lines, original, modified,
- )
- assert_true(calculation.effective_mode == mode)
- assert_false(calculation.fell_back)
- assert_true(calculation.changes.is_empty())
- }
-}
-
-///|
-test "ignore comments keeps slashes inside strings diffable" {
- let original = "let value = \"//old\" // stable"
- let modified = "let value = \"//new\" // stable"
- let calculation = calculate_diff_changes(
- TokenDiff,
- true,
- true,
- [original],
- [modified],
- original,
- modified,
- )
- assert_true(calculation.effective_mode is TokenDiff)
- assert_false(calculation.fell_back)
- assert_false(calculation.changes.is_empty())
- assert_false(
- diff_presentation(calculation.changes).original_inner_changed.is_empty(),
- )
-}
-
-///|
-test "tree parse fallback keeps ignore-comments token semantics" {
- let original = "fn broken( { old_value // obsolete note"
- let modified = "fn broken( { new_value // updated note"
- let calculation = calculate_diff_changes(
- TreeDiff,
- true,
- true,
- [original],
- [modified],
- original,
- modified,
- )
- assert_true(calculation.effective_mode is TokenDiff)
- assert_true(calculation.fell_back)
- assert_false(calculation.changes.is_empty())
-}
-
-///|
-test "ignored one-sided rows align panes without change decoration" {
- let original_lines = ["old_one()", "old_two()"]
- let modified_lines = ["new_one()", "// inserted note", "new_two()"]
- let document : @tokdiff.DiffDocument = {
- hunks: [
- {
- old_start: 0,
- old_len: 2,
- new_start: 0,
- new_len: 3,
- blocks: [
- ChangeBlock([
- Paired(
- { index: 0, segments: [{ kind: Changed, text: "old_one()" }] },
- { index: 0, segments: [{ kind: Changed, text: "new_one()" }] },
- ),
- ]),
- IgnoredBlock([
- NewOnly({
- index: 1,
- segments: [{ kind: Ignored, text: "// inserted note" }],
- }),
- ]),
- ChangeBlock([
- Paired(
- { index: 1, segments: [{ kind: Changed, text: "old_two()" }] },
- { index: 2, segments: [{ kind: Changed, text: "new_two()" }] },
- ),
- ]),
- ],
- },
- ],
- }
- guard moondiff_document_changes(document, original_lines, modified_lines)
- is Some(changes) else {
- fail("expected a valid comment-aware document")
- }
- assert_eq(changes.length(), 3)
- assert_true(changes[0].render_change)
- assert_false(changes[1].render_change)
- assert_true(changes[2].render_change)
- let presentation = diff_presentation(changes)
- assert_eq(presentation.original_changed, [LineRange(1, 2), LineRange(2, 3)])
- assert_eq(presentation.modified_changed, [LineRange(1, 2), LineRange(3, 4)])
- assert_eq(presentation.original_spacers.length(), 1)
- assert_eq(presentation.original_spacers[0].after_line_number, 1)
- assert_eq(presentation.original_spacers[0].height_in_lines, 1)
- assert_true(presentation.modified_spacers.is_empty())
-}
-
-///|
-test "invalid token document is rejected at the standard fallback boundary" {
- let invalid : @tokdiff.DiffDocument = {
- hunks: [
- {
- old_start: 0,
- old_len: 1,
- new_start: 0,
- new_len: 0,
- blocks: [
- ChangeBlock([
- OldOnly({ index: 4, segments: [{ kind: Changed, text: "old" }] }),
- ]),
- ],
- },
- ],
- }
- assert_true(moondiff_document_changes(invalid, ["old"], []) is None)
-}
-
-///|
-test "moondiff controls are limited to exact mbt paths" {
- fn model(path : String) -> @model.TextModel {
- TextModel(
- try! @base_common.Uri::from(scheme="memory", path~),
- path,
- "moonbit",
- 1,
- "test",
- "fn main {}",
- )
- }
- let original = model("/main.mbt")
- let source = model("/MAIN.MBT")
- assert_true(DiffEditorModel(original, source).supports_moondiff())
- let interface = model("/pkg.generated.mbti")
- assert_false(DiffEditorModel(original, interface).supports_moondiff())
- let markdown = model("/README.mbt.md")
- assert_false(DiffEditorModel(original, markdown).supports_moondiff())
- original.dispose()
- source.dispose()
- interface.dispose()
- markdown.dispose()
-}
diff --git a/editor/viewer/markdown_definition_navigation.mbt b/editor/viewer/markdown_definition_navigation.mbt
deleted file mode 100644
index b9760d6ed..000000000
--- a/editor/viewer/markdown_definition_navigation.mbt
+++ /dev/null
@@ -1,273 +0,0 @@
-///|
-/// Root-owned adapter from one rendered Markdown projection to the shared
-/// definition contribution. It never creates a virtual code model: native
-/// caret hits are reduced to original-model offsets before entering provider
-/// state.
-priv struct MarkdownDocumentDefinitionBridge {
- viewer : Viewer
- view : @markdown_document.MarkdownDocumentView
- model : @model.TextModel
- attach_generation : Int
- listeners : Array[@base_common.Disposable]
- mut active : Bool
- mut disposed : Bool
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::MarkdownDocumentDefinitionBridge(
- viewer : Viewer,
- view : @markdown_document.MarkdownDocumentView,
- model : @model.TextModel,
- attach_generation : Int,
-) -> MarkdownDocumentDefinitionBridge {
- {
- viewer,
- view,
- model,
- attach_generation,
- listeners: [],
- active: false,
- disposed: false,
- }
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::is_current(
- self : MarkdownDocumentDefinitionBridge,
-) -> Bool {
- self.active &&
- !self.disposed &&
- self.viewer.is_current_model_data_generation(self.attach_generation) &&
- self.viewer.is_current_model(self.model) &&
- !self.model.is_disposed() &&
- self.view.source_model_version() == self.model.get_version_id()
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::listen(
- self : MarkdownDocumentDefinitionBridge,
- element : @rdom.Element,
- event_name : String,
- listener : @rdom.Listener,
-) -> Unit {
- element.add_event_listener(event_name, listener)
- self.listeners.push(
- @base_common.Disposable::from(() => {
- element.remove_event_listener(event_name, listener)
- }),
- )
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::target_at(
- self : MarkdownDocumentDefinitionBridge,
- client_x : Double,
- client_y : Double,
-) -> @definition.DefinitionTargetFingerprint? {
- guard self.is_current() &&
- self.view.semantic_pointer_hit(client_x, client_y) is Some(hit) else {
- return None
- }
- self.viewer.definition_target_from_source_offset(hit.source_offset)
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::bind_semantic_dom(
- self : MarkdownDocumentDefinitionBridge,
-) -> Unit {
- guard self.is_current() else { return }
- self.view.listen_semantic_dom("mousemove", event => {
- guard event.to_mouse_event() is Some(mouse) else {
- self.viewer.clear_markdown_context_menu_anchor()
- self.viewer.reset_definition_link()
- return
- }
- let client_x = mouse.get_client_x().to_double()
- let client_y = mouse.get_client_y().to_double()
- let target = self.target_at(client_x, client_y)
- self.viewer.handle_definition_pointer_move(
- target,
- mouse.get_ctrl_key(),
- mouse.get_meta_key(),
- mouse.get_alt_key(),
- mouse.get_shift_key(),
- mouse.get_buttons(),
- )
- if mouse.get_buttons() == 0 {
- self.viewer.record_markdown_context_menu_anchor(
- target, client_x, client_y,
- )
- } else {
- self.viewer.clear_markdown_context_menu_anchor()
- }
- })
- self.view.listen_semantic_dom("contextmenu", event => {
- guard self.is_current() && event.to_mouse_event() is Some(mouse) else {
- return
- }
- let client_x = mouse.get_client_x().to_double()
- let client_y = mouse.get_client_y().to_double()
- if self.viewer.show_markdown_context_menu(
- self.target_at(client_x, client_y),
- client_x,
- client_y,
- ) {
- event.prevent_default()
- event.stop_propagation()
- }
- })
- self.view.listen_semantic_dom("mousedown", event => {
- guard self.is_current() && event.to_mouse_event() is Some(mouse) else {
- return
- }
- let standard = @base_browser.StandardMouseEvent(mouse)
- guard standard.left_button && standard.detail <= 1 else { return }
- let target = self.target_at(
- mouse.get_client_x().to_double(),
- mouse.get_client_y().to_double(),
- )
- self.viewer.focus()
- let modifier_active = definition_link_modifier_is_active(
- standard.ctrl_key,
- standard.meta_key,
- standard.alt_key,
- standard.shift_key,
- )
- if !modifier_active {
- self.viewer.commit_plain_definition_pointer_target(target)
- } else if self.viewer.before_definition_target_mouse_down(target) {
- event.prevent_default()
- event.stop_propagation()
- }
- })
- self.view.listen_semantic_dom("mouseup", event => {
- guard self.is_current() && event.to_mouse_event() is Some(mouse) else {
- return
- }
- let standard = @base_browser.StandardMouseEvent(mouse)
- let handled = self.viewer.handle_definition_pointer_up(
- self.target_at(
- mouse.get_client_x().to_double(),
- mouse.get_client_y().to_double(),
- ),
- mouse.get_ctrl_key(),
- mouse.get_meta_key(),
- mouse.get_alt_key(),
- mouse.get_shift_key(),
- left_button=standard.left_button,
- detail=standard.detail,
- )
- if handled {
- event.prevent_default()
- event.stop_propagation()
- }
- })
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::activate(
- self : MarkdownDocumentDefinitionBridge,
-) -> Unit {
- guard !self.active && !self.disposed else { return }
- self.active = true
- self.listen(self.view.root(), "keydown", event => {
- guard self.is_current() &&
- @base_browser.event_target_is(event, self.view.root()) &&
- event.to_keyboard_event() is Some(keyboard) else {
- return
- }
- self.viewer.handle_definition_modifier_key(
- keyboard.key(),
- true,
- keyboard.ctrl_key(),
- keyboard.meta_key(),
- keyboard.alt_key(),
- keyboard.shift_key(),
- )
- if self.viewer.handle_markdown_definition_key_down(
- keyboard.key(),
- keyboard.alt_key(),
- keyboard.ctrl_key() || keyboard.meta_key(),
- keyboard.shift_key(),
- ) {
- event.prevent_default()
- event.stop_propagation()
- }
- })
- self.listen(self.view.root(), "keyup", event => {
- guard self.is_current() &&
- @base_browser.event_target_is(event, self.view.root()) &&
- event.to_keyboard_event() is Some(keyboard) else {
- return
- }
- self.viewer.handle_definition_modifier_key(
- keyboard.key(),
- false,
- keyboard.ctrl_key(),
- keyboard.meta_key(),
- keyboard.alt_key(),
- keyboard.shift_key(),
- )
- })
- self.listen(self.view.root(), "mouseleave", _ => {
- // Inserting the detached menu under the stationary pointer can make the
- // browser synthesize a leave from the Markdown article. Retire only the
- // modifier gesture while that menu is visible so Escape followed by
- // Shift+F10 can still use the clicked semantic source anchor.
- if self.viewer.context_menu_is_visible() {
- self.viewer.cancel_definition_link()
- } else {
- self.viewer.clear_markdown_context_menu_anchor()
- self.viewer.reset_definition_link()
- }
- })
- // Peek moves focus into its overlay while the provider request remains
- // anchored to the last semantic pointer hit.
- self.listen(self.view.root(), "blur", _ => {
- self.viewer.cancel_definition_link()
- })
- self.listen(self.view.viewport(), "scroll", _ => {
- self.viewer.clear_markdown_context_menu_anchor()
- self.viewer.reset_definition_link()
- })
- self.bind_semantic_dom()
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::before_projection_replaced(
- self : MarkdownDocumentDefinitionBridge,
-) -> Unit {
- guard !self.disposed else { return }
- self.viewer.reset_definition_requests()
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::after_projection_replaced(
- self : MarkdownDocumentDefinitionBridge,
-) -> Unit {
- guard !self.disposed else { return }
- self.bind_semantic_dom()
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::layout_changed(
- self : MarkdownDocumentDefinitionBridge,
-) -> Unit {
- guard !self.disposed else { return }
- self.viewer.clear_markdown_context_menu_anchor()
- self.viewer.reset_definition_link()
-}
-
-///|
-fn MarkdownDocumentDefinitionBridge::dispose(
- self : MarkdownDocumentDefinitionBridge,
-) -> Unit {
- if self.disposed {
- return
- }
- self.disposed = true
- self.viewer.reset_definition_requests()
- while self.listeners.length() > 0 {
- self.listeners.remove(self.listeners.length() - 1).dispose()
- }
-}
diff --git a/editor/viewer/markdown_document_presentation.mbt b/editor/viewer/markdown_document_presentation.mbt
deleted file mode 100644
index 10d9448bb..000000000
--- a/editor/viewer/markdown_document_presentation.mbt
+++ /dev/null
@@ -1,48 +0,0 @@
-///|
-/// Exact automatic presentation classifier. Lowercase `.md` includes
-/// `.mbt.md`; the language-id arm supports in-memory and untitled Markdown
-/// models. No query/fragment text participates because matching is on the
-/// decoded URI path component.
-fn document_presentation_kind(
- model : @model.TextModel,
-) -> DocumentPresentationKind {
- if model.uri.path.has_suffix(".md") || model.get_language_id() == "markdown" {
- let resource_kind = if model.uri.path.has_suffix(".mbt.md") {
- @markdown_core.MarkdownResourceKind::MoonBitMarkdown
- } else {
- OrdinaryMarkdown
- }
- MarkdownDocument(resource_kind)
- } else {
- CodeDocument
- }
-}
-
-///|
-fn Viewer::on_markdown_document_focus_changed(
- self : Viewer,
- focused : Bool,
-) -> Unit {
- match self.mount {
- Headless => ()
- Mounted(mounted) =>
- if mounted.set_editor_focus(focused) {
- self.emit_view_event(ViewFocusChanged(ViewFocusChangedEvent(focused)))
- self.schedule_render()
- }
- }
-}
-
-///|
-fn Viewer::on_markdown_document_scroll(self : Viewer) -> Unit {
- guard self.current_markdown_document() is Some(_) else { return }
- self.scroll_dirty = true
- self.schedule_render()
-}
-
-///|
-fn Viewer::on_markdown_document_size_changed(self : Viewer) -> Unit {
- guard self.current_markdown_document() is Some(_) else { return }
- self.scroll_dirty = true
- self.schedule_render()
-}
diff --git a/editor/viewer/markdown_document_presentation_wbtest.mbt b/editor/viewer/markdown_document_presentation_wbtest.mbt
deleted file mode 100644
index eddba3068..000000000
--- a/editor/viewer/markdown_document_presentation_wbtest.mbt
+++ /dev/null
@@ -1,64 +0,0 @@
-///|
-fn markdown_presentation_test_model(
- path : String,
- language_id : String,
- source : String,
-) -> @model.TextModel {
- TextModel(
- try! @base_common.Uri::from(
- scheme="inmemory",
- authority="markdown-tests",
- path~,
- ),
- path,
- language_id,
- 1,
- "markdown-presentation-rev-1",
- source,
- )
-}
-
-///|
-fn assert_markdown_kind(
- model : @model.TextModel,
- expected_resource_kind : @markdown_core.MarkdownResourceKind,
-) -> Unit raise {
- match document_presentation_kind(model) {
- MarkdownDocument(actual) => assert_true(actual == expected_resource_kind)
- CodeDocument => fail("expected Markdown document presentation")
- }
-}
-
-///|
-test "Markdown presentation classifier is exact over URI path and language id" {
- let ordinary = markdown_presentation_test_model(
- "/README.md", "plaintext", "# ordinary",
- )
- let moonbit = markdown_presentation_test_model(
- "/src/literate.mbt.md", "moonbit", "# literate",
- )
- let untitled = markdown_presentation_test_model(
- "/untitled", "markdown", "# untitled",
- )
- let uppercase = markdown_presentation_test_model(
- "/README.MD", "plaintext", "# code",
- )
- defer {
- ordinary.dispose()
- moonbit.dispose()
- untitled.dispose()
- uppercase.dispose()
- }
- assert_markdown_kind(ordinary, OrdinaryMarkdown)
- assert_markdown_kind(moonbit, MoonBitMarkdown)
- assert_markdown_kind(untitled, OrdinaryMarkdown)
- assert_true(document_presentation_kind(uppercase) is CodeDocument)
-
- let viewer = Viewer::Viewer()
- viewer.set_model(Some(moonbit))
- assert_false(viewer.is_code_presentation())
- assert_true(viewer.current_browser_presentation() is None)
- viewer.set_model(Some(uppercase))
- assert_true(viewer.is_code_presentation())
- viewer.dispose()
-}
diff --git a/editor/viewer/markdown_folding.mbt b/editor/viewer/markdown_folding.mbt
deleted file mode 100644
index 455f4473e..000000000
--- a/editor/viewer/markdown_folding.mbt
+++ /dev/null
@@ -1,306 +0,0 @@
-// Root-owned Markdown section-fold state. The document view owns only the
-// control DOM and the visibility attribute; every policy decision -- which
-// sections exist, what starts collapsed, what a reader override means, and how
-// state survives an agent edit -- lives here. Package ownership is documented
-// in viewer/README.mbt.md and internal/viewer/markdown/README.mbt.md.
-
-///|
-/// One section's retained fold state. `fingerprint` is the exact body source
-/// text at the time the record was written; reconciliation refuses to carry a
-/// record onto a section whose body has changed.
-///
-/// The review asked for tri-state over a collapsed-key set so that a reader who
-/// expands an auto-collapsed section is distinguishable from one who never
-/// touched it. This record map subsumes that: a retained `collapsed=false`
-/// record *is* that distinction, and because reconciliation never re-runs the
-/// auto-fold policy, no separate override tag is observable. If a future policy
-/// re-run is added, the explicit tag must return with it.
-priv struct MarkdownFoldRecord {
- mut collapsed : Bool
- fingerprint : String
-}
-
-///|
-/// Model-scoped fold state, keyed by the section reconciliation key
-/// (`@markdown_core.markdown_section_keys`). Created empty per model
-/// attachment and never carried across a model swap.
-priv struct MarkdownFoldState {
- mut source : String
- mut section_keys : Array[String]
- records : Map[String, MarkdownFoldRecord]
-}
-
-///|
-fn MarkdownFoldState::MarkdownFoldState() -> MarkdownFoldState {
- { source: "", section_keys: [], records: Map([]) }
-}
-
-///|
-/// Seeds the fold state for a freshly attached model: every section starts at
-/// the auto-fold policy (`markdown_default_collapsed_sections`) with no reader
-/// override, then the DOM is brought in line.
-fn MarkdownBrowserData::initialize_markdown_folding(
- self : MarkdownBrowserData,
- source : String,
-) -> Unit {
- let projection = self.view.projection()
- let sections = @markdown_core.markdown_sections(projection)
- let keys = @markdown_core.markdown_section_keys(sections, projection, source)
- let defaults = @markdown_core.markdown_default_collapsed_sections(
- projection, source,
- )
- self.fold.source = source
- self.fold.section_keys = keys
- self.fold.records.clear()
- for section_index, section in sections {
- guard section_index < keys.length() else { continue }
- self.fold.records[keys[section_index]] = {
- collapsed: defaults.contains(section_index),
- fingerprint: markdown_fold_fingerprint(section, source),
- }
- }
- self.view.set_section_fold_toggle_handler(section_index => {
- self.toggle_markdown_section(section_index) |> ignore
- })
- self.apply_markdown_folding()
- self.apply_markdown_toc()
-}
-
-///|
-/// Conservative reconciliation after a source or theme reprojection: when
-/// section identity or content is uncertain, expand.
-///
-/// A record survives only when its key still resolves *and* the section's body
-/// text is unchanged. A theme change trivially satisfies both, so fold state
-/// is preserved exactly; an agent edit preserves state for untouched sections
-/// and opens everything changed, new, or ambiguous. Newly appearing sections
-/// are *not* auto-collapsed: the reader is presumably interested in what the
-/// agent just wrote.
-///
-/// Runs inside the caller's hover-bridge replacement bracket, and therefore
-/// must not call `layout_changed` itself: `before_projection_replaced` already
-/// invalidated the pending request, and `after_projection_replaced` refreshes
-/// diagnostics only after final visibility is installed here.
-fn MarkdownBrowserData::reconcile_markdown_folding(
- self : MarkdownBrowserData,
- source : String,
-) -> Unit {
- let projection = self.view.projection()
- let sections = @markdown_core.markdown_sections(projection)
- let keys = @markdown_core.markdown_section_keys(sections, projection, source)
- let retained : Map[String, MarkdownFoldRecord] = Map([])
- for section_index, section in sections {
- guard section_index < keys.length() else { continue }
- let key = keys[section_index]
- let fingerprint = markdown_fold_fingerprint(section, source)
- retained[key] = match self.fold.records.get(key) {
- Some(record) if record.fingerprint == fingerprint => record
- _ => { collapsed: false, fingerprint }
- }
- }
- self.fold.source = source
- self.fold.section_keys = keys
- self.fold.records.clear()
- for key, record in retained {
- self.fold.records[key] = record
- }
- self.apply_markdown_folding()
- // The article and projection were just replaced; rows depend on the
- // projection, not on collapse state, so this is the one reconciliation
- // point that rebuilds them. Fold toggles leave the rows (and keyboard
- // focus within them) untouched.
- self.apply_markdown_toc()
-}
-
-///|
-/// Flips one section between collapsed and expanded as an explicit reader
-/// decision, then republishes visibility and invalidates the hover bridge.
-///
-/// Ordering is DOM visibility first (`apply_markdown_folding`),
-/// `layout_changed` second, so a pending hover
-/// request over content that just disappeared can never complete against
-/// stale geometry.
-fn MarkdownBrowserData::toggle_markdown_section(
- self : MarkdownBrowserData,
- section_index : Int,
-) -> Bool {
- guard section_index >= 0 && section_index < self.fold.section_keys.length() else {
- return false
- }
- guard self.fold.records.get(self.fold.section_keys[section_index])
- is Some(record) else {
- return false
- }
- record.collapsed = !record.collapsed
- self.apply_markdown_folding()
- self.hover_bridge.layout_changed()
- true
-}
-
-///|
-/// Publishes the current fold state into the view: the union of every
-/// collapsed section's hideable run, plus one fold control per foldable
-/// heading. Pure visibility -- never a re-render, never a projection rebuild.
-fn MarkdownBrowserData::apply_markdown_folding(
- self : MarkdownBrowserData,
-) -> Unit {
- let projection = self.view.projection()
- let sections = @markdown_core.markdown_sections(projection)
- let hidden : Set[Int] = Set([])
- let controls : Array[@markdown_document.MarkdownSectionFoldControl] = []
- for section_index, section in sections {
- guard section.is_foldable(projection) &&
- section_index < self.fold.section_keys.length() else {
- continue
- }
- let collapsed = match
- self.fold.records.get(self.fold.section_keys[section_index]) {
- Some(record) => record.collapsed
- None => false
- }
- match
- projection.block_anchors[section.heading_anchor_index].rendered_element_index {
- Some(heading_element_index) =>
- controls.push({ heading_element_index, section_index, collapsed })
- None => continue
- }
- if collapsed {
- for element_index in section.body_rendered_element_indexes(projection) {
- hidden.add(element_index)
- }
- }
- }
- let hidden_sorted = hidden.to_array()
- hidden_sorted.sort()
- self.view.set_hidden_root_elements(hidden_sorted)
- self.view.install_section_fold_controls(controls)
-}
-
-///|
-/// The section's exact body source text. Records store it so reconciliation
-/// can tell an untouched section from a rewritten one; identical heading plus
-/// identical body means hiding either candidate shows the reader the same
-/// document, so key-level ambiguity is already conservative enough.
-fn markdown_fold_fingerprint(
- section : @markdown_core.MarkdownSection,
- source : String,
-) -> String {
- match section.body_source_range {
- Some(range) => {
- let start = range.start.max(0).min(source.length())
- let end = range.end_exclusive.max(start).min(source.length())
- source[start:end].to_owned()
- }
- None => ""
- }
-}
-
-///|
-/// Collapses or expands the Markdown section owning `source_offset` (a 0-based
-/// UTF-16 offset into the original model text, the innermost section winning),
-/// as if the reader had clicked its fold control. Returns `false` when the
-/// current presentation is not Markdown or no foldable section owns the
-/// offset.
-///
-/// This is the host-facing seam the table-of-contents UI and agent drivers
-/// use; the control DOM itself routes through the same toggle.
-pub fn Viewer::toggle_markdown_section_at(
- self : Viewer,
- source_offset : Int,
-) -> Bool {
- guard self.current_markdown_browser_data() is Some(data) else { return false }
- let projection = data.view.projection()
- let sections = @markdown_core.markdown_sections(projection)
- guard @markdown_core.markdown_section_at_source_offset(
- sections, source_offset,
- )
- is Some(section) else {
- return false
- }
- guard section.is_foldable(projection) else { return false }
- for section_index, candidate in sections {
- if candidate.heading_anchor_index == section.heading_anchor_index {
- return data.toggle_markdown_section(section_index)
- }
- }
- false
-}
-
-///|
-/// The heading source offsets of every currently collapsed Markdown section,
-/// in document order; empty for a Code presentation. The offsets are the same
-/// coordinates `toggle_markdown_section_at` accepts, so read-back and control
-/// share one coordinate system.
-pub fn Viewer::collapsed_markdown_sections(self : Viewer) -> Array[Int] {
- let collapsed = []
- guard self.current_markdown_browser_data() is Some(data) else {
- return collapsed
- }
- let projection = data.view.projection()
- let sections = @markdown_core.markdown_sections(projection)
- for section_index, section in sections {
- guard section_index < data.fold.section_keys.length() else { continue }
- match data.fold.records.get(data.fold.section_keys[section_index]) {
- Some(record) if record.collapsed =>
- collapsed.push(
- projection.block_anchors[section.heading_anchor_index].source_range.start,
- )
- _ => ()
- }
- }
- collapsed
-}
-
-///|
-/// Publishes the table-of-contents bar from the same section tree folding
-/// uses, so an outline row and a fold control can never disagree. Runs inside
-/// `apply_markdown_folding`'s callers via initialization/reconciliation.
-fn MarkdownBrowserData::apply_markdown_toc(self : MarkdownBrowserData) -> Unit {
- let projection = self.view.projection()
- let entries = @markdown_core.markdown_table_of_contents(projection).map(entry => {
- let bar_entry : @markdown_document.MarkdownTocBarEntry = {
- depth: entry.depth,
- text: entry.text,
- source_offset: entry.source_offset,
- }
- bar_entry
- })
- self.view.set_toc_entries(entries, offset => {
- self.navigate_markdown_section(offset)
- })
-}
-
-///|
-/// TOC navigation: expand the target section and every collapsed ancestor
-/// (the review's reveal rule -- a reveal anchor's whole chain stays open),
-/// republish visibility, and invalidate the hover bridge. The TOC view scrolls
-/// its own viewport after this callback returns, when the expanded heading has
-/// a current layout rectangle. Expansions are recorded as explicit reader
-/// decisions: the reader asked to see this content, so a later unrelated agent
-/// edit must not re-collapse it.
-fn MarkdownBrowserData::navigate_markdown_section(
- self : MarkdownBrowserData,
- source_offset : Int,
-) -> Unit {
- let projection = self.view.projection()
- let sections = @markdown_core.markdown_sections(projection)
- let mut changed = false
- for section_index, section in sections {
- guard section.source_range.contains(source_offset) &&
- section_index < self.fold.section_keys.length() else {
- continue
- }
- match self.fold.records.get(self.fold.section_keys[section_index]) {
- Some(record) if record.collapsed => {
- record.collapsed = false
- changed = true
- }
- _ => ()
- }
- }
- if changed {
- self.apply_markdown_folding()
- }
- // A pending hover must not survive the geometry change.
- self.hover_bridge.layout_changed()
-}
diff --git a/editor/viewer/markdown_viewer_facade.mbt b/editor/viewer/markdown_viewer_facade.mbt
new file mode 100644
index 000000000..08fda0f79
--- /dev/null
+++ b/editor/viewer/markdown_viewer_facade.mbt
@@ -0,0 +1,333 @@
+///|
+pub(all) enum MarkdownResourceKind {
+ OrdinaryMarkdown
+ MoonBitMarkdown
+} derive(Eq, Debug)
+
+///|
+pub(all) struct MarkdownViewerModel {
+ model : @model.TextModel
+ resource_kind : MarkdownResourceKind
+}
+
+///|
+pub fn MarkdownViewerModel::MarkdownViewerModel(
+ model : @model.TextModel,
+ resource_kind : MarkdownResourceKind,
+) -> MarkdownViewerModel {
+ { model, resource_kind }
+}
+
+///|
+struct MarkdownViewerOptions {
+ theme : String
+ placeholder : String
+ show_unused : Bool
+} derive(Eq, Debug)
+
+///|
+pub fn MarkdownViewerOptions::MarkdownViewerOptions(
+ theme? : String = "dark",
+ placeholder? : String = "",
+ show_unused? : Bool = true,
+) -> MarkdownViewerOptions {
+ { theme, placeholder, show_unused }
+}
+
+///|
+pub fn MarkdownViewerOptions::default() -> MarkdownViewerOptions {
+ MarkdownViewerOptions()
+}
+
+///|
+pub fn MarkdownViewerOptions::with_theme(
+ self : MarkdownViewerOptions,
+ theme : String,
+) -> MarkdownViewerOptions {
+ { ..self, theme, }
+}
+
+///|
+pub fn MarkdownViewerOptions::with_placeholder(
+ self : MarkdownViewerOptions,
+ placeholder : String,
+) -> MarkdownViewerOptions {
+ { ..self, placeholder, }
+}
+
+///|
+pub fn MarkdownViewerOptions::with_show_unused(
+ self : MarkdownViewerOptions,
+ show_unused : Bool,
+) -> MarkdownViewerOptions {
+ { ..self, show_unused, }
+}
+
+///|
+struct MarkdownViewerViewState {
+ inner : @markdown_viewer.MarkdownViewerWidgetViewState
+} derive(Debug)
+
+///|
+struct MarkdownViewer {
+ widget : @markdown_viewer.MarkdownViewerWidget
+ borrowed_services : @markdown_viewer.MarkdownViewerServices?
+}
+
+///|
+fn MarkdownResourceKind::internal(
+ self : MarkdownResourceKind,
+) -> @markdown_viewer.MarkdownResourceKind {
+ match self {
+ OrdinaryMarkdown => OrdinaryMarkdown
+ MoonBitMarkdown => MoonBitMarkdown
+ }
+}
+
+///|
+fn MarkdownViewerOptions::internal(
+ self : MarkdownViewerOptions,
+) -> @markdown_viewer.MarkdownViewerWidgetOptions {
+ MarkdownViewerWidgetOptions(
+ theme=self.theme,
+ placeholder=self.placeholder,
+ show_unused=self.show_unused,
+ )
+}
+
+///|
+fn ViewerServices::markdown_services(
+ self : ViewerServices,
+ options : MarkdownViewerOptions,
+) -> @markdown_viewer.MarkdownViewerServices {
+ MarkdownViewerServices(
+ languages=self.languages(),
+ markers=Decorations(self.marker_decorations()),
+ location_opener?=self.location_opener(),
+ text_model_resolver?=self.text_model_resolver(),
+ preview_factory=@code_widget.code_editor_references_preview_factory(
+ self.code_services(),
+ CodeEditorOptions(theme=options.theme),
+ ),
+ log_service=self.log_service(),
+ )
+}
+
+///|
+pub fn MarkdownViewer::create(
+ host : @rdom.Element,
+ services? : ViewerServices,
+ options? : MarkdownViewerOptions = MarkdownViewerOptions::default(),
+) -> MarkdownViewer {
+ match services {
+ Some(services) => {
+ let markdown_services = services.markdown_services(options)
+ {
+ widget: @markdown_viewer.MarkdownViewerWidget::create(
+ host,
+ services=markdown_services,
+ options=options.internal(),
+ ),
+ borrowed_services: Some(markdown_services),
+ }
+ }
+ None =>
+ {
+ widget: @markdown_viewer.MarkdownViewerWidget::create(
+ host,
+ options=options.internal(),
+ ),
+ borrowed_services: None,
+ }
+ }
+}
+
+///|
+pub fn MarkdownViewer::set_model(
+ self : MarkdownViewer,
+ model : MarkdownViewerModel?,
+) -> MarkdownViewerModel? {
+ self.widget
+ .set_model(
+ model.map(model => {
+ model: model.model,
+ resource_kind: model.resource_kind.internal(),
+ }),
+ )
+ .map(previous => {
+ model: previous.model,
+ resource_kind: match previous.resource_kind {
+ OrdinaryMarkdown => OrdinaryMarkdown
+ MoonBitMarkdown => MoonBitMarkdown
+ },
+ })
+}
+
+///|
+pub fn MarkdownViewer::get_model(self : MarkdownViewer) -> MarkdownViewerModel? {
+ self.widget
+ .get_model()
+ .map(model => {
+ model: model.model,
+ resource_kind: match model.resource_kind {
+ OrdinaryMarkdown => OrdinaryMarkdown
+ MoonBitMarkdown => MoonBitMarkdown
+ },
+ })
+}
+
+///|
+pub fn MarkdownViewer::on_did_change_model(
+ self : MarkdownViewer,
+ listener : (@editor_api.ModelChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_model(listener)
+}
+
+///|
+pub fn MarkdownViewer::on_did_change_model_content(
+ self : MarkdownViewer,
+ listener : (@model.ModelContentChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_model_content(listener)
+}
+
+///|
+pub fn MarkdownViewer::on_did_scroll_change(
+ self : MarkdownViewer,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_scroll(listener)
+}
+
+///|
+pub fn MarkdownViewer::on_did_dispose(
+ self : MarkdownViewer,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_dispose(listener)
+}
+
+///|
+pub fn MarkdownViewer::handle_initialized(self : MarkdownViewer) -> Unit {
+ self.widget.layout()
+}
+
+///|
+pub fn MarkdownViewer::update_options(
+ self : MarkdownViewer,
+ options : MarkdownViewerOptions,
+) -> Unit {
+ self.widget.update_options(options.internal())
+}
+
+///|
+pub fn MarkdownViewer::get_options(
+ self : MarkdownViewer,
+) -> MarkdownViewerOptions {
+ let options = self.widget.get_options()
+ MarkdownViewerOptions(
+ theme=options.theme(),
+ placeholder=options.placeholder(),
+ show_unused=options.show_unused(),
+ )
+}
+
+///|
+pub fn MarkdownViewer::save_view_state(
+ self : MarkdownViewer,
+) -> MarkdownViewerViewState? {
+ self.widget.save_view_state().map(inner => { inner, })
+}
+
+///|
+pub fn MarkdownViewer::restore_view_state(
+ self : MarkdownViewer,
+ state : MarkdownViewerViewState?,
+) -> Unit {
+ self.widget.restore_view_state(state.map(state => state.inner))
+}
+
+///|
+pub fn MarkdownViewer::reveal_range_in_center(
+ self : MarkdownViewer,
+ range : @base_common.Range,
+) -> Unit {
+ guard self.get_model() is Some(model) else { return }
+ self.widget.reveal_source_offset(
+ model.model.snapshot.get_offset_at(
+ range.start_line_number,
+ range.start_column,
+ ),
+ )
+}
+
+///|
+pub fn MarkdownViewer::collapsed_sections(self : MarkdownViewer) -> Array[Int] {
+ self.widget.collapsed_sections()
+}
+
+///|
+pub fn MarkdownViewer::toggle_section_at(
+ self : MarkdownViewer,
+ section_index : Int,
+) -> Bool {
+ self.widget.toggle_section_at(section_index)
+}
+
+///|
+pub fn MarkdownViewer::get_dom_node(self : MarkdownViewer) -> @rdom.Element? {
+ self.widget.get_dom_node()
+}
+
+///|
+pub fn MarkdownViewer::get_container_dom_node(
+ self : MarkdownViewer,
+) -> @rdom.Element {
+ self.widget.get_container_dom_node()
+}
+
+///|
+pub fn MarkdownViewer::get_id(self : MarkdownViewer) -> String {
+ self.widget.get_id()
+}
+
+///|
+pub fn MarkdownViewer::get_content_height(self : MarkdownViewer) -> Double {
+ self.widget.get_content_height()
+}
+
+///|
+pub fn MarkdownViewer::get_content_width(self : MarkdownViewer) -> Double {
+ self.widget.get_content_width()
+}
+
+///|
+pub fn MarkdownViewer::layout(self : MarkdownViewer) -> Unit {
+ self.widget.layout()
+}
+
+///|
+pub fn MarkdownViewer::focus(self : MarkdownViewer) -> Unit {
+ self.widget.focus()
+}
+
+///|
+pub fn MarkdownViewer::has_text_focus(self : MarkdownViewer) -> Bool {
+ self.widget.has_text_focus()
+}
+
+///|
+pub fn MarkdownViewer::dispose(self : MarkdownViewer) -> Unit {
+ self.widget.dispose()
+ match self.borrowed_services {
+ Some(services) => services.dispose()
+ None => ()
+ }
+}
+
+///|
+pub extend MarkdownResourceKind with Eq::{equal, not_equal}
+
+///|
+pub extend MarkdownResourceKind with Debug::{to_repr}
diff --git a/editor/viewer/model_data.mbt b/editor/viewer/model_data.mbt
deleted file mode 100644
index 448b1d398..000000000
--- a/editor/viewer/model_data.mbt
+++ /dev/null
@@ -1,372 +0,0 @@
-///|
-/// Viewer-lifetime owner of the currently attached model bundle and the
-/// monotonic generation captured by swap-scoped callbacks. Detach invalidates
-/// the generation before cleanup but deliberately keeps `current` installed
-/// until `ModelData::dispose` has completed.
-priv struct ViewerModelSlot {
- mut generation : Int
- mut current : ModelData?
-}
-
-///|
-fn ViewerModelSlot::ViewerModelSlot() -> ViewerModelSlot {
- { generation: 0, current: None }
-}
-
-///|
-/// Advances the callback generation before one model attach begins.
-fn ViewerModelSlot::begin_attach_generation(self : ViewerModelSlot) -> Int {
- self.generation += 1
- self.generation
-}
-
-///|
-/// Invalidates swap-scoped callbacks before detach cleanup while leaving the
-/// outgoing bundle available to the root transaction through its exact
-/// presentation -> root removal -> listener -> attached-view -> ViewModel
-/// disposal order.
-fn ViewerModelSlot::invalidate_current_for_detach(
- self : ViewerModelSlot,
-) -> ModelData? {
- guard self.current is Some(current) else { return None }
- self.generation += 1
- Some(current)
-}
-
-///|
-fn ViewerModelSlot::install_current(
- self : ViewerModelSlot,
- model_data : ModelData,
-) -> Unit {
- self.current = Some(model_data)
-}
-
-///|
-/// Clears the outgoing bundle only after its owned resources have been
-/// disposed. This must not be folded into `invalidate_current_for_detach`.
-fn ViewerModelSlot::clear_current_after_dispose(self : ViewerModelSlot) -> Unit {
- self.current = None
-}
-
-///|
-fn ViewerModelSlot::is_current_generation(
- self : ViewerModelSlot,
- generation : Int,
-) -> Bool {
- self.current is Some(_) && self.generation == generation
-}
-
-///|
-/// Monaco's `ModelData` (`codeEditorWidget.ts:2123-2143`): everything whose
-/// lifetime is one model attach. `browser` is `None` only for the private
-/// headless harness; every mounted model owns one closed browser presentation.
-priv struct ModelData {
- model : @model.TextModel
- view_model : @view_model.ViewModel
- presentation_kind : DocumentPresentationKind
- browser : BrowserPresentation?
- attached_view : @model.AttachedViewImpl
- listeners_to_remove : Array[@base_common.Disposable]
-}
-
-///|
-/// Private automatic document-presentation selection. URI matching is exact
-/// and case-sensitive; an in-memory/untitled `markdown` model also selects the
-/// document view without inventing a file suffix.
-priv enum DocumentPresentationKind {
- CodeDocument
- MarkdownDocument(@markdown_core.MarkdownResourceKind)
-}
-
-///|
-/// The private closed family of mounted document presentations. Every root,
-/// focus, theme, and lifecycle operation routes exhaustively through this
-/// family instead of assuming a hidden code `View`.
-priv enum BrowserPresentation {
- Code(CodeBrowserData)
- Markdown(MarkdownBrowserData)
-}
-
-///|
-/// Root-owned composition for the Markdown presentation. The lower document
-/// view remains contribution-free; this payload enforces cancel → retire
-/// semantic lifetime → rerender → rebind/project ordering for every source or
-/// theme replacement.
-priv struct MarkdownBrowserData {
- view : @markdown_document.MarkdownDocumentView
- hover_bridge : @hover_browser.MarkdownDocumentHoverBridge
- definition_bridge : MarkdownDocumentDefinitionBridge
- fold : MarkdownFoldState
-}
-
-///|
-fn MarkdownBrowserData::activate(self : MarkdownBrowserData) -> Unit {
- self.hover_bridge.activate()
- self.definition_bridge.activate()
-}
-
-///|
-fn MarkdownBrowserData::replace_source(
- self : MarkdownBrowserData,
- source : String,
- source_model_version : Int,
-) -> Unit {
- self.definition_bridge.before_projection_replaced()
- self.hover_bridge.before_projection_replaced()
- self.view.replace_source(source, source_model_version)
- // Fold reconciliation must land inside the replacement bracket: the
- // pending hover is already invalidated, and `after_projection_replaced`
- // may only refresh diagnostics once final visibility is installed.
- self.reconcile_markdown_folding(source)
- self.hover_bridge.after_projection_replaced()
- self.definition_bridge.after_projection_replaced()
-}
-
-///|
-fn MarkdownBrowserData::apply_theme(
- self : MarkdownBrowserData,
- theme : String,
-) -> Unit {
- self.definition_bridge.before_projection_replaced()
- self.hover_bridge.before_projection_replaced()
- self.view.apply_theme(theme)
- // The source is unchanged, so reconciliation preserves fold state exactly;
- // what needs re-publishing is the visibility and controls the re-rendered
- // article just lost.
- self.reconcile_markdown_folding(self.fold.source)
- self.hover_bridge.after_projection_replaced()
- self.definition_bridge.after_projection_replaced()
-}
-
-///|
-fn MarkdownBrowserData::layout(
- self : MarkdownBrowserData,
- width : Double,
- height : Double,
-) -> Unit {
- self.view.layout(width, height)
- self.hover_bridge.layout_changed()
- self.definition_bridge.layout_changed()
-}
-
-///|
-fn MarkdownBrowserData::dispose(self : MarkdownBrowserData) -> Unit {
- self.definition_bridge.dispose()
- self.hover_bridge.dispose()
- self.view.dispose()
-}
-
-///|
-/// The complete code-presentation state for one attached model.
-/// `mouse_handler` is retained for lifecycle commands, but `view` remains its
-/// sole disposable owner through the disposable registered by
-/// `hook_view_input`.
-priv struct CodeBrowserData {
- view : @view.View
- mouse_handler : @controller.MouseHandler
- mut rendered_window : @view_layout.LineWindow?
- mut horizontal_reveal_request : HorizontalRevealRangeRequest?
-}
-
-///|
-fn CodeBrowserData::CodeBrowserData(
- view : @view.View,
- mouse_handler : @controller.MouseHandler,
-) -> CodeBrowserData {
- {
- view,
- mouse_handler,
- rendered_window: None,
- horizontal_reveal_request: None,
- }
-}
-
-///|
-fn CodeBrowserData::cancel_mouse_selection(self : CodeBrowserData) -> Unit {
- self.mouse_handler.cancel_selection()
-}
-
-///|
-fn CodeBrowserData::invalidate_rendered_window(self : CodeBrowserData) -> Unit {
- self.rendered_window = None
-}
-
-///|
-fn CodeBrowserData::sync_rendered_window(
- self : CodeBrowserData,
- view_model : @view_model.ViewModel,
-) -> @view_layout.LineWindow {
- let window = view_model.clamp_window(view_model.view_layout.visible_window())
- self.rendered_window = Some(window)
- window
-}
-
-///|
-fn BrowserPresentation::root(self : BrowserPresentation) -> @rdom.Element {
- match self {
- Code(code) => code.view.root
- Markdown(markdown) => markdown.view.root()
- }
-}
-
-///|
-fn BrowserPresentation::apply_theme(
- self : BrowserPresentation,
- theme : String,
-) -> Unit {
- match self {
- Code(code) => {
- code.view.root.set_attribute("data-theme", theme)
- code.view.apply_squiggly_theme()
- }
- Markdown(markdown) => markdown.apply_theme(theme)
- }
-}
-
-///|
-fn BrowserPresentation::focus(self : BrowserPresentation) -> Unit {
- @base_browser.focus_prevent_scroll(self.root())
-}
-
-///|
-fn BrowserPresentation::contains_active_element(
- self : BrowserPresentation,
-) -> Bool {
- @base_browser.element_contains_active_element(self.root())
-}
-
-///|
-fn BrowserPresentation::code(self : BrowserPresentation) -> CodeBrowserData? {
- match self {
- Code(code) => Some(code)
- Markdown(_) => None
- }
-}
-
-///|
-fn BrowserPresentation::markdown(
- self : BrowserPresentation,
-) -> @markdown_document.MarkdownDocumentView? {
- match self {
- Code(_) => None
- Markdown(markdown) => Some(markdown.view)
- }
-}
-
-///|
-fn BrowserPresentation::markdown_data(
- self : BrowserPresentation,
-) -> MarkdownBrowserData? {
- match self {
- Code(_) => None
- Markdown(markdown) => Some(markdown)
- }
-}
-
-///|
-fn BrowserPresentation::activate(self : BrowserPresentation) -> Unit {
- match self {
- Code(_) => ()
- Markdown(markdown) => markdown.activate()
- }
-}
-
-///|
-fn BrowserPresentation::dispose(self : BrowserPresentation) -> Unit {
- match self {
- Code(code) => code.view.dispose()
- Markdown(markdown) => markdown.dispose()
- }
-}
-
-///|
-fn Viewer::current_browser_presentation(self : Viewer) -> BrowserPresentation? {
- match self.model_slot.current {
- Some(model_data) => model_data.browser
- None => None
- }
-}
-
-///|
-fn Viewer::active_presentation_root(self : Viewer) -> @rdom.Element? {
- match self.current_browser_presentation() {
- Some(presentation) => Some(presentation.root())
- None => None
- }
-}
-
-///|
-fn Viewer::current_code_browser_data(self : Viewer) -> CodeBrowserData? {
- match self.current_browser_presentation() {
- Some(presentation) => presentation.code()
- None => None
- }
-}
-
-///|
-fn Viewer::current_markdown_document(
- self : Viewer,
-) -> @markdown_document.MarkdownDocumentView? {
- match self.current_browser_presentation() {
- Some(presentation) => presentation.markdown()
- None => None
- }
-}
-
-///|
-fn Viewer::current_markdown_browser_data(self : Viewer) -> MarkdownBrowserData? {
- match self.current_browser_presentation() {
- Some(presentation) => presentation.markdown_data()
- None => None
- }
-}
-
-///|
-/// Shared first half of the original ModelData order.
-fn ModelData::dispose_listeners_and_attached_view(
- self : ModelData,
- after_model_detached : () -> Unit,
-) -> Unit {
- for listener in self.listeners_to_remove {
- listener.dispose()
- }
- self.model.on_before_detached(self.attached_view)
- after_model_detached()
-}
-
-///|
-/// Preserve Monaco's existing Code/headless order exactly: listeners,
-/// attached-view handle, browser View when present, then ViewModel.
-fn ModelData::dispose_code_or_headless(
- self : ModelData,
- after_model_detached : () -> Unit,
-) -> Unit {
- self.dispose_listeners_and_attached_view(after_model_detached)
- match self.browser {
- Some(browser) => browser.dispose()
- None => ()
- }
- self.view_model.dispose()
-}
-
-///|
-/// Retires the Markdown browser-owned bridge, renderer, and semantic registry
-/// while its presentation root is still mounted.
-fn ModelData::dispose_markdown_presentation(self : ModelData) -> Unit {
- match self.browser {
- Some(Markdown(markdown)) => markdown.dispose()
- Some(Code(_)) | None => ()
- }
-}
-
-///|
-/// Remaining `ModelData` teardown (`cew:2133-2141`) after root Viewer has
-/// removed the disposed presentation subtree: remove swap-scoped listeners,
-/// release the attached-view handle, then unregister the ViewModel.
-fn ModelData::dispose_after_markdown_root(
- self : ModelData,
- after_model_detached : () -> Unit,
-) -> Unit {
- self.dispose_listeners_and_attached_view(after_model_detached)
- self.view_model.dispose()
-}
diff --git a/editor/viewer/moon.pkg b/editor/viewer/moon.pkg
index 8f863570e..17b47ac55 100644
--- a/editor/viewer/moon.pkg
+++ b/editor/viewer/moon.pkg
@@ -1,16 +1,12 @@
import {
- "moonbitlang/editor/base/browser" @base_browser,
"moonbitlang/editor/base/common" @base_common,
"moonbitlang/editor/language",
"moonbitlang/editor/platform/log",
"moonbitlang/editor/viewer/browser" @editor_browser,
- "moonbitlang/editor/viewer/browser/diagram_viewport",
- "moonbitlang/editor/internal/viewer/browser/config" @browser_config,
- "moonbitlang/editor/internal/viewer/browser/testing" @viewer_testing,
- "moonbitlang/editor/internal/viewer/browser/view",
- "moonbitlang/editor/internal/viewer/browser/controller",
+ "moonbitlang/editor/internal/viewer/code_editor_widget" @code_widget,
+ "moonbitlang/editor/internal/viewer/diff_editor",
+ "moonbitlang/editor/internal/viewer/markdown_viewer",
"moonbitlang/editor/viewer/common/config",
- "moonbitlang/editor/viewer/common/cursor",
"moonbitlang/editor/viewer/common/editor_api",
"moonbitlang/editor/viewer/common/core",
"moonbitlang/editor/viewer/common/diff",
@@ -18,40 +14,10 @@ import {
"moonbitlang/editor/viewer/common/agent_feedback_api",
"moonbitlang/editor/viewer/common/navigation_api",
"moonbitlang/editor/viewer/common/quick_diff_api",
- "moonbitlang/editor/viewer/common/unified_diff",
- "moonbitlang/editor/internal/viewer/contrib/agent_feedback",
- "moonbitlang/editor/internal/viewer/contrib/agent_feedback/browser" @agent_feedback_browser,
- "moonbitlang/editor/internal/viewer/contrib/contextmenu/browser" @contextmenu_browser,
- "moonbitlang/editor/internal/viewer/contrib/definition",
- "moonbitlang/editor/internal/viewer/contrib/definition/browser" @definition_browser,
- "moonbitlang/editor/internal/viewer/contrib/references",
- "moonbitlang/editor/internal/viewer/contrib/references/browser" @references_browser,
- "moonbitlang/editor/internal/viewer/contrib/folding/browser" @folding_browser,
- "moonbitlang/editor/internal/viewer/contrib/hover",
- "moonbitlang/editor/internal/viewer/contrib/hover/browser" @hover_browser,
- "moonbitlang/editor/internal/viewer/contrib/markdown_comments",
- "moonbitlang/editor/internal/viewer/contrib/markdown_comments/browser" @markdown_comments_browser,
"moonbitlang/editor/internal/viewer/markdown" @markdown_core,
- "moonbitlang/editor/internal/viewer/browser/markdown" @browser_markdown,
- "moonbitlang/editor/internal/viewer/browser/markdown_document",
- "moonbitlang/editor/internal/viewer/browser/unified_diff" @unified_diff_browser,
- "moonbitlang/editor/internal/viewer/contrib/quick_diff/common" @quick_diff,
- "moonbitlang/editor/internal/viewer/contrib/quick_diff/browser" @quick_diff_browser,
"moonbitlang/editor/viewer/common/languages",
"moonbitlang/editor/viewer/common/markers",
- "moonbitlang/editor/internal/viewer/ui/scrollbar",
- "moonbitlang/editor/viewer/common/view_layout",
- "moonbitlang/editor/viewer/common/view_model",
- "moonbit-community/moondiff/mbtdiff",
- "moonbit-community/moondiff/tokdiff",
"moonbit-community/rabbita/dom" @rdom,
- "moonbit-community/rabbita/js",
}
-import {
- "moonbitlang/async",
- "moonbitlang/editor/syntax",
- "moonbitlang/editor/syntax/examples/plain_tokenizer",
-} for "wbtest"
-
supported_targets = "js"
diff --git a/editor/viewer/pkg.generated.mbti b/editor/viewer/pkg.generated.mbti
index a2099e15f..f00346dfe 100644
--- a/editor/viewer/pkg.generated.mbti
+++ b/editor/viewer/pkg.generated.mbti
@@ -11,13 +11,13 @@ import {
"moonbitlang/editor/viewer/common/agent_feedback_api",
"moonbitlang/editor/viewer/common/config",
"moonbitlang/editor/viewer/common/core",
+ "moonbitlang/editor/viewer/common/diff",
"moonbitlang/editor/viewer/common/editor_api",
"moonbitlang/editor/viewer/common/languages",
"moonbitlang/editor/viewer/common/markers",
"moonbitlang/editor/viewer/common/model",
"moonbitlang/editor/viewer/common/navigation_api",
"moonbitlang/editor/viewer/common/quick_diff_api",
- "moonbitlang/editor/viewer/common/unified_diff",
}
// Values
@@ -25,44 +25,55 @@ pub fn overlay_widget(String, @dom.Element) -> @browser.OverlayWidget
pub fn render_uml_diagram_svg(String) -> String?
-pub fn view_zone(Int, @dom.Element, after_column? : Int, after_column_affinity? : @model.PositionAffinity, show_in_hidden_areas? : Bool, ignore_hidden_area_source? : String, ordinal? : Int, suppress_mouse_down? : Bool, height_in_lines? : Double, height_in_px? : Double, min_width_in_px? : Double, margin_dom_node? : @dom.Element, on_dom_node_top? : (Double) -> Unit raise, on_computed_height? : (Double) -> Unit raise) -> @browser.ViewZone
+pub fn view_zone(Int, @dom.Element, after_column? : Int, after_column_affinity? : @model.PositionAffinity, show_in_hidden_areas? : Bool, ignore_hidden_area_source? : String, ordinal? : Int, suppress_mouse_down? : Bool, height_in_lines? : Double, height_in_px? : Double, min_width_in_px? : Double, margin_dom_node? : @dom.Element, on_dom_node_top? : (Double) -> Unit raise, on_dom_node_layout? : (Bool, Double) -> Unit raise, on_computed_height? : (Double) -> Unit raise) -> @browser.ViewZone
// Errors
// Types and methods
+type DiffEditor
+pub fn DiffEditor::create(@dom.Element, services? : ViewerServices, options? : DiffEditorOptions, provider? : &@diff.DocumentDiffProvider) -> Self
+pub fn DiffEditor::dispose(Self) -> Unit
+pub fn DiffEditor::focus(Self) -> Unit
+pub fn DiffEditor::get_diff(Self) -> @diff.DocumentDiff?
+pub fn DiffEditor::get_layout(Self) -> DiffEditorLayout
+pub fn DiffEditor::get_model(Self) -> DiffEditorModel?
+pub fn DiffEditor::is_diff_up_to_date(Self) -> Bool
+pub fn DiffEditor::layout(Self) -> Unit
+pub fn DiffEditor::on_did_update_diff(Self, () -> Unit) -> @common.Disposable
+pub fn DiffEditor::reveal_next_change(Self) -> Bool
+pub fn DiffEditor::reveal_previous_change(Self) -> Bool
+pub fn DiffEditor::set_diff_provider(Self, &@diff.DocumentDiffProvider) -> Unit
+pub fn DiffEditor::set_model(Self, DiffEditorModel?) -> DiffEditorModel?
+pub fn DiffEditor::update_options(Self, DiffEditorOptions) -> Unit
+
+pub(all) enum DiffEditorLayout {
+ SideBySide
+ Inline
+} derive(Eq, @debug.Debug)
+pub fn DiffEditorLayout::equal(Self, Self) -> Bool
+pub fn DiffEditorLayout::not_equal(Self, Self) -> Bool
+pub fn DiffEditorLayout::to_repr(Self) -> @debug.Repr
+
pub(all) struct DiffEditorModel {
original : @model.TextModel
modified : @model.TextModel
}
pub fn DiffEditorModel::DiffEditorModel(@model.TextModel, @model.TextModel) -> Self
-pub(all) enum DiffEditorRenderMode {
- SideBySide
- Unified
+type DiffEditorOptions
+pub fn DiffEditorOptions::DiffEditorOptions(editor_options? : ViewerOptions, layout? : DiffEditorLayout, automatic_inline? : Bool, automatic_inline_width? : Double, sash_enabled? : Bool, split_ratio? : Double, diff_word_wrap? : DiffWordWrap, overview_ruler? : Bool, ignore_trim_whitespace? : Bool) -> Self
+pub fn DiffEditorOptions::default() -> Self
+pub fn DiffEditorOptions::layout(Self) -> DiffEditorLayout
+pub fn DiffEditorOptions::with_editor_options(Self, ViewerOptions) -> Self
+pub fn DiffEditorOptions::with_layout(Self, DiffEditorLayout) -> Self
+
+pub(all) enum DiffWordWrap {
+ Inherit
+ Off
} derive(Eq, @debug.Debug)
-pub fn DiffEditorRenderMode::equal(Self, Self) -> Bool
-pub fn DiffEditorRenderMode::not_equal(Self, Self) -> Bool
-pub fn DiffEditorRenderMode::to_repr(Self) -> @debug.Repr
-
-type DiffViewer
-pub fn DiffViewer::clear(Self) -> Unit
-pub fn DiffViewer::create(@dom.Element, services? : ViewerServices, options? : ViewerOptions, render_mode? : DiffEditorRenderMode) -> Self
-pub fn DiffViewer::dispose(Self) -> Unit
-pub fn DiffViewer::get_diff_mode(Self) -> DiffViewerMode
-pub fn DiffViewer::get_model(Self) -> DiffEditorModel?
-pub fn DiffViewer::get_render_mode(Self) -> DiffEditorRenderMode
-pub fn DiffViewer::layout(Self) -> Unit
-pub fn DiffViewer::set_diff_mode(Self, DiffViewerMode) -> Unit
-pub fn DiffViewer::set_model(Self, DiffEditorModel?) -> Unit
-pub fn DiffViewer::set_render_mode(Self, DiffEditorRenderMode) -> Unit
-
-pub(all) enum DiffViewerMode {
- StandardDiff
- TokenDiff
- TreeDiff
-} derive(Eq)
-pub fn DiffViewerMode::equal(Self, Self) -> Bool
-pub fn DiffViewerMode::not_equal(Self, Self) -> Bool
+pub fn DiffWordWrap::equal(Self, Self) -> Bool
+pub fn DiffWordWrap::not_equal(Self, Self) -> Bool
+pub fn DiffWordWrap::to_repr(Self) -> @debug.Repr
type EditorDecorationsCollection
pub fn EditorDecorationsCollection::append(Self, Array[@model.ModelDeltaDecoration]) -> Array[String]
@@ -72,16 +83,58 @@ pub fn EditorDecorationsCollection::get_ranges(Self) -> Array[@common.Range]
pub fn EditorDecorationsCollection::length(Self) -> Int
pub fn EditorDecorationsCollection::set(Self, Array[@model.ModelDeltaDecoration]) -> Array[String]
-type UnifiedDiffView
-pub fn UnifiedDiffView::clear(Self) -> Unit
-pub fn UnifiedDiffView::create(@dom.Element, on_comment? : (@unified_diff.UnifiedDiffLineComment) -> Unit) -> Self
-pub fn UnifiedDiffView::dispose(Self) -> Unit
-pub fn UnifiedDiffView::set_diff(Self, String, String, context_lines? : Int) -> Unit
+pub(all) enum MarkdownResourceKind {
+ OrdinaryMarkdown
+ MoonBitMarkdown
+} derive(Eq, @debug.Debug)
+pub fn MarkdownResourceKind::equal(Self, Self) -> Bool
+pub fn MarkdownResourceKind::not_equal(Self, Self) -> Bool
+pub fn MarkdownResourceKind::to_repr(Self) -> @debug.Repr
+
+type MarkdownViewer
+pub fn MarkdownViewer::collapsed_sections(Self) -> Array[Int]
+pub fn MarkdownViewer::create(@dom.Element, services? : ViewerServices, options? : MarkdownViewerOptions) -> Self
+pub fn MarkdownViewer::dispose(Self) -> Unit
+pub fn MarkdownViewer::focus(Self) -> Unit
+pub fn MarkdownViewer::get_container_dom_node(Self) -> @dom.Element
+pub fn MarkdownViewer::get_content_height(Self) -> Double
+pub fn MarkdownViewer::get_content_width(Self) -> Double
+pub fn MarkdownViewer::get_dom_node(Self) -> @dom.Element?
+pub fn MarkdownViewer::get_id(Self) -> String
+pub fn MarkdownViewer::get_model(Self) -> MarkdownViewerModel?
+pub fn MarkdownViewer::get_options(Self) -> MarkdownViewerOptions
+pub fn MarkdownViewer::handle_initialized(Self) -> Unit
+pub fn MarkdownViewer::has_text_focus(Self) -> Bool
+pub fn MarkdownViewer::layout(Self) -> Unit
+pub fn MarkdownViewer::on_did_change_model(Self, (@editor_api.ModelChangedEvent) -> Unit) -> @common.Disposable
+pub fn MarkdownViewer::on_did_change_model_content(Self, (@model.ModelContentChangedEvent) -> Unit) -> @common.Disposable
+pub fn MarkdownViewer::on_did_dispose(Self, () -> Unit) -> @common.Disposable
+pub fn MarkdownViewer::on_did_scroll_change(Self, () -> Unit) -> @common.Disposable
+pub fn MarkdownViewer::restore_view_state(Self, MarkdownViewerViewState?) -> Unit
+pub fn MarkdownViewer::reveal_range_in_center(Self, @common.Range) -> Unit
+pub fn MarkdownViewer::save_view_state(Self) -> MarkdownViewerViewState?
+pub fn MarkdownViewer::set_model(Self, MarkdownViewerModel?) -> MarkdownViewerModel?
+pub fn MarkdownViewer::toggle_section_at(Self, Int) -> Bool
+pub fn MarkdownViewer::update_options(Self, MarkdownViewerOptions) -> Unit
+
+pub(all) struct MarkdownViewerModel {
+ model : @model.TextModel
+ resource_kind : MarkdownResourceKind
+}
+pub fn MarkdownViewerModel::MarkdownViewerModel(@model.TextModel, MarkdownResourceKind) -> Self
+
+type MarkdownViewerOptions derive(Eq, @debug.Debug)
+pub fn MarkdownViewerOptions::MarkdownViewerOptions(theme? : String, placeholder? : String, show_unused? : Bool) -> Self
+pub fn MarkdownViewerOptions::default() -> Self
+pub fn MarkdownViewerOptions::with_placeholder(Self, String) -> Self
+pub fn MarkdownViewerOptions::with_show_unused(Self, Bool) -> Self
+pub fn MarkdownViewerOptions::with_theme(Self, String) -> Self
+
+type MarkdownViewerViewState derive(@debug.Debug)
type Viewer
pub fn Viewer::add_overlay_widget(Self, @browser.OverlayWidget) -> Unit
pub fn Viewer::change_view_zones(Self, (@browser.ViewZoneChangeAccessor) -> Unit raise) -> Unit
-pub fn Viewer::collapsed_markdown_sections(Self) -> Array[Int]
pub fn Viewer::create(@dom.Element, services? : ViewerServices, options? : ViewerOptions) -> Self
pub fn Viewer::create_decorations_collection(Self, decorations? : Array[@model.ModelDeltaDecoration]) -> EditorDecorationsCollection
pub fn Viewer::delta_decorations(Self, Array[String], Array[@model.ModelDeltaDecoration]) -> Array[String]
@@ -164,15 +217,12 @@ pub fn Viewer::set_selection(Self, @core.Selection, source? : String) -> Unit
pub fn Viewer::set_selections(Self, Array[@core.Selection], source? : String, reason? : @editor_api.CursorChangeReason) -> Unit
pub fn Viewer::set_value(Self, String) -> Unit
pub fn Viewer::show_references(Self, @common.Position, Array[@language.Location]) -> Unit
-pub fn Viewer::toggle_markdown_section_at(Self, Int) -> Bool
pub fn Viewer::update_options(Self, ViewerOptions) -> Unit
type ViewerOptions derive(Eq, @debug.Debug)
pub fn ViewerOptions::ViewerOptions(soft_wrap? : Bool, tab_size? : Int, wrapping_indent? : @editor_api.WrappingIndent, render_whitespace? : @editor_api.RenderWhitespace, render_control_characters? : Bool, render_line_highlight? : @editor_api.RenderLineHighlight, render_line_highlight_only_when_focus? : Bool, render_validation_decorations? : @editor_api.RenderValidationDecorations, theme? : String, placeholder? : String, line_decorations_width? : Int, line_numbers_min_chars? : Int, folding? : Bool, show_folding_controls? : @editor_api.ShowFoldingControls, folding_highlight? : Bool, folding_maximum_regions? : Int, unfold_on_click_after_end_of_line? : Bool, smooth_scrolling? : Bool, show_unused? : Bool, show_deprecated? : Bool, font_family? : String, font_weight? : String, font_size? : Double, line_height? : Double, letter_spacing? : Double) -> Self
pub fn ViewerOptions::default() -> Self
-pub fn ViewerOptions::equal(Self, Self) -> Bool
pub fn ViewerOptions::line_height(Self) -> Double
-pub fn ViewerOptions::not_equal(Self, Self) -> Bool
pub fn ViewerOptions::soft_wrap(Self) -> Bool
pub fn ViewerOptions::with_font_family(Self, String) -> Self
pub fn ViewerOptions::with_font_size(Self, Double) -> Self
diff --git a/editor/viewer/presentation_routing.mbt b/editor/viewer/presentation_routing.mbt
deleted file mode 100644
index 7551010cf..000000000
--- a/editor/viewer/presentation_routing.mbt
+++ /dev/null
@@ -1,150 +0,0 @@
-///|
-/// Whether the installed model currently exposes the code-editor behavior
-/// family. Presentation selection is retained for headless models as well as
-/// mounted ones, so callers do not infer Code merely from a missing DOM root.
-fn ModelData::is_code_presentation(self : ModelData) -> Bool {
- match self.presentation_kind {
- CodeDocument => true
- MarkdownDocument(_) => false
- }
-}
-
-///|
-/// Root gate for cursor state, code contributions, and source-editor events.
-fn Viewer::is_code_presentation(self : Viewer) -> Bool {
- match self.model_slot.current {
- Some(model_data) => model_data.is_code_presentation()
- None => false
- }
-}
-
-///|
-/// Scroll truth for the active presentation. Mounted Markdown reads its native
-/// viewport, Code reads `ViewLayout`, and headless Markdown has no scroll
-/// surface.
-fn Viewer::active_scroll_position(
- self : Viewer,
-) -> @view_layout.ScrollPosition? {
- guard self.model_slot.current is Some(model_data) else { return None }
- match model_data.browser {
- Some(Markdown(markdown)) =>
- Some({
- scroll_top: markdown.view.scroll_top(),
- scroll_left: markdown.view.scroll_left(),
- })
- Some(Code(_)) | None =>
- if model_data.is_code_presentation() {
- Some(model_data.view_model.view_layout.position())
- } else {
- None
- }
- }
-}
-
-///|
-fn Viewer::active_content_width(self : Viewer) -> Double? {
- guard self.model_slot.current is Some(model_data) else { return None }
- match model_data.browser {
- Some(Markdown(markdown)) => Some(markdown.view.content_width())
- Some(Code(_)) | None =>
- if model_data.is_code_presentation() {
- Some(model_data.view_model.view_layout.get_content_width())
- } else {
- None
- }
- }
-}
-
-///|
-fn Viewer::active_scroll_width(self : Viewer) -> Double? {
- guard self.model_slot.current is Some(model_data) else { return None }
- match model_data.browser {
- Some(Markdown(markdown)) => Some(markdown.view.scroll_width())
- Some(Code(_)) | None =>
- if model_data.is_code_presentation() {
- Some(model_data.view_model.view_layout.get_scroll_width())
- } else {
- None
- }
- }
-}
-
-///|
-fn Viewer::active_content_height(self : Viewer) -> Double? {
- guard self.model_slot.current is Some(model_data) else { return None }
- match model_data.browser {
- Some(Markdown(markdown)) => Some(markdown.view.content_height())
- Some(Code(_)) | None =>
- if model_data.is_code_presentation() {
- Some(model_data.view_model.view_layout.get_content_height())
- } else {
- None
- }
- }
-}
-
-///|
-fn Viewer::active_scroll_height(self : Viewer) -> Double? {
- guard self.model_slot.current is Some(model_data) else { return None }
- match model_data.browser {
- Some(Markdown(markdown)) => Some(markdown.view.scroll_height())
- Some(Code(_)) | None =>
- if model_data.is_code_presentation() {
- Some(model_data.view_model.view_layout.get_scroll_height())
- } else {
- None
- }
- }
-}
-
-///|
-fn Viewer::set_active_scroll_position(
- self : Viewer,
- scroll_top? : Double,
- scroll_left? : Double,
-) -> Unit {
- guard self.model_slot.current is Some(model_data) else { return }
- match model_data.browser {
- Some(Markdown(markdown)) => {
- markdown.view.set_scroll_position(scroll_top?, scroll_left?)
- self.on_markdown_document_scroll()
- }
- Some(Code(_)) | None => {
- guard model_data.is_code_presentation() else { return }
- let layout = model_data.view_model.view_layout
- let position = layout.position()
- layout.scroll_to(
- scroll_top=scroll_top.unwrap_or(position.scroll_top),
- scroll_left=scroll_left.unwrap_or(position.scroll_left),
- )
- |> ignore
- }
- }
-}
-
-///|
-fn Viewer::stabilize_active_scroll(self : Viewer) -> Unit {
- guard self.model_slot.current is Some(model_data) else { return }
- match model_data.browser {
- Some(Markdown(_)) => ()
- Some(Code(_)) | None =>
- if model_data.is_code_presentation() {
- model_data.view_model.visible_lines_stabilized()
- }
- }
-}
-
-///|
-/// Layout publication after the host box has been measured. Code layout is
-/// already committed through `ViewLayout`; this explicit dispatch is the seam
-/// for independent presentation roots.
-fn Viewer::layout_active_presentation(
- self : Viewer,
- _width : Double,
- _height : Double,
-) -> Unit {
- match self.current_browser_presentation() {
- Some(Markdown(markdown)) => markdown.layout(_width, _height)
- Some(Code(_)) | None => ()
- }
-}
diff --git a/editor/viewer/public_viewer_options.mbt b/editor/viewer/public_viewer_options.mbt
new file mode 100644
index 000000000..1a2d327b7
--- /dev/null
+++ b/editor/viewer/public_viewer_options.mbt
@@ -0,0 +1,200 @@
+///|
+/// Opaque public options for the standalone code surface. The canonical
+/// option state lives in the code kernel; this wrapper prevents the internal
+/// package type from leaking through the public package boundary.
+struct ViewerOptions {
+ inner : @code_widget.CodeEditorOptions
+} derive(Eq, Debug)
+
+///|
+pub fn ViewerOptions::ViewerOptions(
+ soft_wrap? : Bool = false,
+ tab_size? : Int = 2,
+ wrapping_indent? : @editor_api.WrappingIndent = Same,
+ render_whitespace? : @editor_api.RenderWhitespace = Selection,
+ render_control_characters? : Bool = true,
+ render_line_highlight? : @editor_api.RenderLineHighlight = Line,
+ render_line_highlight_only_when_focus? : Bool = false,
+ render_validation_decorations? : @editor_api.RenderValidationDecorations = On,
+ theme? : String = "dark",
+ placeholder? : String = "",
+ line_decorations_width? : Int = 10,
+ line_numbers_min_chars? : Int = 5,
+ folding? : Bool = true,
+ show_folding_controls? : @editor_api.ShowFoldingControls = Mouseover,
+ folding_highlight? : Bool = true,
+ folding_maximum_regions? : Int = 5000,
+ unfold_on_click_after_end_of_line? : Bool = false,
+ smooth_scrolling? : Bool = false,
+ show_unused? : Bool = true,
+ show_deprecated? : Bool = true,
+ font_family? : String = @config.editor_font_defaults.font_family,
+ font_weight? : String = @config.editor_font_defaults.font_weight,
+ font_size? : Double = @config.editor_font_defaults.font_size,
+ line_height? : Double = @config.editor_font_defaults.line_height,
+ letter_spacing? : Double = @config.editor_font_defaults.letter_spacing,
+) -> ViewerOptions {
+ {
+ inner: CodeEditorOptions(
+ soft_wrap~,
+ tab_size~,
+ wrapping_indent~,
+ render_whitespace~,
+ render_control_characters~,
+ render_line_highlight~,
+ render_line_highlight_only_when_focus~,
+ render_validation_decorations~,
+ theme~,
+ placeholder~,
+ line_decorations_width~,
+ line_numbers_min_chars~,
+ folding~,
+ show_folding_controls~,
+ folding_highlight~,
+ folding_maximum_regions~,
+ unfold_on_click_after_end_of_line~,
+ smooth_scrolling~,
+ show_unused~,
+ show_deprecated~,
+ font_family~,
+ font_weight~,
+ font_size~,
+ line_height~,
+ letter_spacing~,
+ ),
+ }
+}
+
+///|
+pub fn ViewerOptions::default() -> ViewerOptions {
+ { inner: @code_widget.CodeEditorOptions::default() }
+}
+
+///|
+pub fn ViewerOptions::with_placeholder(
+ self : ViewerOptions,
+ value : String,
+) -> ViewerOptions {
+ { inner: self.inner.with_placeholder(value) }
+}
+
+///|
+pub fn ViewerOptions::with_theme(
+ self : ViewerOptions,
+ value : String,
+) -> ViewerOptions {
+ { inner: self.inner.with_theme(value) }
+}
+
+///|
+pub fn ViewerOptions::with_font_family(
+ self : ViewerOptions,
+ value : String,
+) -> ViewerOptions {
+ { inner: self.inner.with_font_family(value) }
+}
+
+///|
+pub fn ViewerOptions::with_font_size(
+ self : ViewerOptions,
+ value : Double,
+) -> ViewerOptions {
+ { inner: self.inner.with_font_size(value) }
+}
+
+///|
+pub fn ViewerOptions::with_render_whitespace(
+ self : ViewerOptions,
+ value : @editor_api.RenderWhitespace,
+) -> ViewerOptions {
+ { inner: self.inner.with_render_whitespace(value) }
+}
+
+///|
+pub fn ViewerOptions::with_render_control_characters(
+ self : ViewerOptions,
+ value : Bool,
+) -> ViewerOptions {
+ { inner: self.inner.with_render_control_characters(value) }
+}
+
+///|
+pub fn ViewerOptions::with_render_line_highlight(
+ self : ViewerOptions,
+ value : @editor_api.RenderLineHighlight,
+) -> ViewerOptions {
+ { inner: self.inner.with_render_line_highlight(value) }
+}
+
+///|
+pub fn ViewerOptions::with_render_line_highlight_only_when_focus(
+ self : ViewerOptions,
+ value : Bool,
+) -> ViewerOptions {
+ { inner: self.inner.with_render_line_highlight_only_when_focus(value) }
+}
+
+///|
+pub fn ViewerOptions::with_render_validation_decorations(
+ self : ViewerOptions,
+ value : @editor_api.RenderValidationDecorations,
+) -> ViewerOptions {
+ { inner: self.inner.with_render_validation_decorations(value) }
+}
+
+///|
+pub fn ViewerOptions::with_tab_size(
+ self : ViewerOptions,
+ value : Int,
+) -> ViewerOptions {
+ { inner: self.inner.with_tab_size(value) }
+}
+
+///|
+pub fn ViewerOptions::with_soft_wrap(
+ self : ViewerOptions,
+ value : Bool,
+) -> ViewerOptions {
+ { inner: self.inner.with_soft_wrap(value) }
+}
+
+///|
+pub fn ViewerOptions::with_line_numbers_min_chars(
+ self : ViewerOptions,
+ value : Int,
+) -> ViewerOptions {
+ { inner: self.inner.with_line_numbers_min_chars(value) }
+}
+
+///|
+pub fn ViewerOptions::with_line_decorations_width(
+ self : ViewerOptions,
+ value : Int,
+) -> ViewerOptions {
+ { inner: self.inner.with_line_decorations_width(value) }
+}
+
+///|
+pub fn ViewerOptions::with_line_height(
+ self : ViewerOptions,
+ value : Double,
+) -> ViewerOptions {
+ { inner: self.inner.with_line_height(value) }
+}
+
+///|
+pub fn ViewerOptions::soft_wrap(self : ViewerOptions) -> Bool {
+ self.inner.soft_wrap()
+}
+
+///|
+pub fn ViewerOptions::line_height(self : ViewerOptions) -> Double {
+ self.inner.line_height()
+}
+
+///|
+fn ViewerOptions::code_options(
+ self : ViewerOptions,
+) -> @code_widget.CodeEditorOptions {
+ self.inner
+}
diff --git a/editor/viewer/references_contribution.mbt b/editor/viewer/references_contribution.mbt
deleted file mode 100644
index 264a0a7f8..000000000
--- a/editor/viewer/references_contribution.mbt
+++ /dev/null
@@ -1,172 +0,0 @@
-///|
-/// Monaco's shared Definition/References Peek controller id. The contribution
-/// is Viewer-owned; the DOM-free result model and browser shell remain in the
-/// lower internal references packages.
-let references_contribution_id : String = "editor.contrib.referencesController"
-
-///|
-/// Exact provider-query owner stamp. The session generation identifies both
-/// the mounted shell and the query intent; model fields reject results after
-/// replacement, reattachment, or content changes.
-priv struct ReferencesQueryStamp {
- generation : Int
- model_identity : Int
- model_generation : Int
- model_version_id : Int
-}
-
-///|
-/// One root-owned lazy snippet-resolution slot. The group index is immutable;
-/// each request bumps `generation`, and the optional source/reference are
-/// detached before cancellation or lease-release callbacks can re-enter.
-priv struct ReferencesGroupPreviewOwner {
- group_index : Int
- mut generation : Int
- mut source : @language.CancellationTokenSource?
- mut reference : @navigation_api.TextModelReference?
-}
-
-///|
-fn ReferencesGroupPreviewOwner::ReferencesGroupPreviewOwner(
- group_index : Int,
-) -> ReferencesGroupPreviewOwner {
- { group_index, generation: 0, source: None, reference: None }
-}
-
-///|
-/// Central per-Viewer owner for the presentation and lifetime shared by
-/// Definition Peek and the precomputed-locations References entry. Definition
-/// provider requests stay in `DefinitionContributionState`; every field here
-/// belongs to the already-computed result session and its selected preview.
-priv struct ReferencesContributionState {
- mut disposed : Bool
- mut generation : Int
- mut query_source : @language.CancellationTokenSource?
- mut mode : @references.ReferencesPeekMode?
- mut phase : @references.ReferencesPeekPhase
- mut preview_generation : Int
- mut preview_source : @language.CancellationTokenSource?
- mut model : @references.ReferencesModel?
- mut selected_index : Int
- mut session_key : @references.ReferencesSessionKey?
- mut widget : @references_browser.ReferencesPeekWidget?
- mut zone_id : String?
- mut overlay_widget : @editor_browser.OverlayWidget?
- mut markdown_projection_generation : Int?
- mut preview_viewer : Viewer?
- mut preview_reference : @navigation_api.TextModelReference?
- mut preview_decorations : EditorDecorationsCollection?
- mut preview_layout_frame : @base_common.Disposable?
- mut preview_resize_observer : @base_common.Disposable?
- mut group_preview_owners : Array[ReferencesGroupPreviewOwner]
- mut focus_frame : @base_common.Disposable?
- mut launch_async_task : (async () -> Unit noraise) -> Unit
-}
-
-///|
-fn ReferencesContributionState::ReferencesContributionState() -> ReferencesContributionState {
- {
- disposed: false,
- generation: 0,
- query_source: None,
- mode: None,
- phase: PeekClosed,
- preview_generation: 0,
- preview_source: None,
- model: None,
- selected_index: -1,
- session_key: None,
- widget: None,
- zone_id: None,
- overlay_widget: None,
- markdown_projection_generation: None,
- preview_viewer: None,
- preview_reference: None,
- preview_decorations: None,
- preview_layout_frame: None,
- preview_resize_observer: None,
- group_preview_owners: [],
- focus_frame: None,
- launch_async_task,
- }
-}
-
-///|
-/// Disposal delegates to the shared root teardown while the central
-/// contribution row is still lookuppable. That teardown atomically detaches
-/// the session before invoking cancellation or lease-release callbacks.
-fn ReferencesContributionState::dispose(
- self : ReferencesContributionState,
- viewer : Viewer,
-) -> Unit {
- if self.disposed {
- return
- }
- self.disposed = true
- viewer.close_references_peek(restore_focus=false)
-}
-
-///|
-/// Retires a completed or stale provider query only when it is still the exact
-/// source owned by this state. An older continuation therefore cannot cancel
-/// a newer References session.
-fn ReferencesContributionState::retire_query_source(
- self : ReferencesContributionState,
- source : @language.CancellationTokenSource,
- cancel~ : Bool,
-) -> Unit {
- match self.query_source {
- Some(current) if physical_equal(current, source) => {
- self.query_source = None
- source.dispose(cancel~)
- }
- _ => ()
- }
-}
-
-///|
-fn register_references_contribution(
- registry : EditorContributionRegistry,
-) -> Unit {
- registry.register_editor_contribution(
- references_contribution_id,
- Lazy,
- _viewer => References(ReferencesContributionState()),
- _viewer => [],
- )
- registry.register_editor_command(
- "editor.action.referenceSearch.trigger",
- viewer => viewer.references_action_available(),
- viewer => viewer.start_peek_references(),
- [],
- shift_keybindings=[("F12", false, false)],
- title="Peek References",
- menu_keybinding_label=references_menu_keybinding_label(),
- menu_placements=[
- { menu_id: EditorContext, group: "navigation", order: 1.3 },
- ],
- )
-}
-
-///|
-fn references_menu_keybinding_label() -> String {
- if @base_common.is_macintosh {
- "\u{21e7}F12"
- } else {
- "Shift+F12"
- }
-}
-
-///|
-fn Viewer::references_contribution(
- self : Viewer,
-) -> ReferencesContributionState? {
- match self.contributions.lookup(references_contribution_id) {
- Some(entry) =>
- match entry.instance {
- References(state) => Some(state)
- _ => None
- }
- None => None
- }
-}
diff --git a/editor/viewer/unified_diff_view.mbt b/editor/viewer/unified_diff_view.mbt
deleted file mode 100644
index fc7d4c85f..000000000
--- a/editor/viewer/unified_diff_view.mbt
+++ /dev/null
@@ -1,97 +0,0 @@
-///|
-/// A standalone readonly unified-diff surface. The host owns source loading
-/// and reserves the supplied element for this view; the component owns its DOM
-/// subtree and computes presentation hunks through the replaceable algorithm
-/// boundary in `viewer/common/unified_diff`.
-struct UnifiedDiffView {
- browser : @unified_diff_browser.UnifiedDiffBrowserView
- mut disposed : Bool
-}
-
-///|
-/// An eager DOM row contains line numbers, a marker, and source text. Keep the
-/// standalone renderer below the point where a legal-size short-line document
-/// can create enough elements to stall or exhaust the host webview.
-const MaxRenderedInputLines = 5000
-
-///|
-/// Reject oversized text before `TextSnapshot` normalizes and indexes it. The
-/// budget is expressed in UTF-16 code units because `String::length` is an
-/// allocation-free O(1) query on the JS target. Checking each side before the
-/// subtraction keeps the combined comparison overflow-safe.
-const MaxRenderedInputCodeUnits = 1048576
-
-///|
-/// Creates an empty unified-diff view in a caller-owned stable host element.
-pub fn UnifiedDiffView::create(
- host : @rdom.Element,
- on_comment? : (@unified_diff.UnifiedDiffLineComment) -> Unit,
-) -> UnifiedDiffView {
- { browser: UnifiedDiffBrowserView(host, on_comment?), disposed: false }
-}
-
-///|
-/// Replaces the current comparison. Inputs are normalized with the same text
-/// snapshot used by the ordinary Viewer, so CRLF and trailing empty lines have
-/// the same meaning in both surfaces.
-pub fn UnifiedDiffView::set_diff(
- self : UnifiedDiffView,
- original_content : String,
- modified_content : String,
- context_lines? : Int = 3,
-) -> Unit {
- if self.disposed {
- return
- }
- let original_length = original_content.length()
- let modified_length = modified_content.length()
- if original_length > MaxRenderedInputCodeUnits ||
- modified_length > MaxRenderedInputCodeUnits ||
- original_length > MaxRenderedInputCodeUnits - modified_length {
- self.browser.set_message(
- "This diff is too large to render safely. Open the source preview instead.",
- )
- return
- }
- let original = @model.TextSnapshot(original_content)
- let modified = @model.TextSnapshot(modified_content)
- if original.line_count() + modified.line_count() > MaxRenderedInputLines {
- self.browser.set_message(
- "This diff is too large to render safely. Open the source preview instead.",
- )
- return
- }
- self.browser.set_hunks(
- @unified_diff.compute_unified_diff(
- snapshot_lines(original),
- snapshot_lines(modified),
- context_lines~,
- ),
- )
-}
-
-///|
-/// Removes the current comparison while keeping the view reusable.
-pub fn UnifiedDiffView::clear(self : UnifiedDiffView) -> Unit {
- if !self.disposed {
- self.browser.clear()
- }
-}
-
-///|
-/// Releases renderer-owned DOM. Disposal is idempotent and never removes the
-/// caller's host element.
-pub fn UnifiedDiffView::dispose(self : UnifiedDiffView) -> Unit {
- if self.disposed {
- return
- }
- self.browser.dispose()
- self.disposed = true
-}
-
-///|
-fn snapshot_lines(snapshot : @model.TextSnapshot) -> Array[String] {
- [
- for index in 0.. snapshot.line_text(index)
- ]
-}
diff --git a/editor/viewer/viewer_facade.mbt b/editor/viewer/viewer_facade.mbt
new file mode 100644
index 000000000..0ca1cc7db
--- /dev/null
+++ b/editor/viewer/viewer_facade.mbt
@@ -0,0 +1,90 @@
+///|
+/// Public standalone-code editor. This is deliberately a real opaque façade,
+/// not a type alias: public consumers cannot name or depend on the internal
+/// kernel package, while sibling diff code can use the narrow internal seams
+/// below during its replacement.
+struct Viewer {
+ widget : @code_widget.CodeEditorWidget
+ mut options : ViewerOptions
+}
+
+///|
+struct ViewerViewState {
+ inner : @code_widget.CodeEditorViewState
+} derive(Debug)
+
+///|
+struct EditorDecorationsCollection {
+ inner : @code_widget.EditorDecorationsCollection
+}
+
+///|
+fn Viewer::Viewer(
+ initial_container : @rdom.Element,
+ services? : ViewerServices,
+ options? : ViewerOptions = ViewerOptions::default(),
+) -> Viewer {
+ let code_options = options.code_options()
+ let services = services.map(service => service.code_services())
+ let widget = @code_widget.CodeEditorWidget::create(
+ initial_container,
+ services?,
+ options=code_options,
+ contributions=@code_widget.CodeEditorContributionDescriptors::standalone(),
+ )
+ { widget, options }
+}
+
+///|
+pub fn Viewer::create(
+ host : @rdom.Element,
+ services? : ViewerServices,
+ options? : ViewerOptions = ViewerOptions::default(),
+) -> Viewer {
+ Viewer(host, services?, options~)
+}
+
+///|
+pub fn view_zone(
+ after_line_number : Int,
+ dom_node : @rdom.Element,
+ after_column? : Int = 0,
+ after_column_affinity? : @model.PositionAffinity = None,
+ show_in_hidden_areas? : Bool = false,
+ ignore_hidden_area_source? : String = "",
+ ordinal? : Int = 0,
+ suppress_mouse_down? : Bool = false,
+ height_in_lines? : Double,
+ height_in_px? : Double,
+ min_width_in_px? : Double = 0.0,
+ margin_dom_node? : @rdom.Element,
+ on_dom_node_top? : (Double) -> Unit raise,
+ on_dom_node_layout? : (Bool, Double) -> Unit raise,
+ on_computed_height? : (Double) -> Unit raise,
+) -> @editor_browser.ViewZone {
+ @code_widget.view_zone(
+ after_line_number,
+ dom_node,
+ after_column~,
+ after_column_affinity~,
+ show_in_hidden_areas~,
+ ignore_hidden_area_source~,
+ ordinal~,
+ suppress_mouse_down~,
+ height_in_lines?,
+ height_in_px?,
+ min_width_in_px~,
+ margin_dom_node?,
+ on_dom_node_top?,
+ on_dom_node_layout?,
+ on_computed_height?,
+ )
+}
+
+///|
+pub fn overlay_widget(
+ id : String,
+ dom_node : @rdom.Element,
+) -> @editor_browser.OverlayWidget {
+ @code_widget.overlay_widget(id, dom_node)
+}
diff --git a/editor/viewer/viewer_facade_forwarding.mbt b/editor/viewer/viewer_facade_forwarding.mbt
new file mode 100644
index 000000000..54350c96e
--- /dev/null
+++ b/editor/viewer/viewer_facade_forwarding.mbt
@@ -0,0 +1,327 @@
+///|
+pub fn Viewer::set_model(self : Viewer, model : @model.TextModel?) -> Unit {
+ self.widget.set_model(model)
+}
+
+///|
+pub fn Viewer::get_model(self : Viewer) -> @model.TextModel? {
+ self.widget.get_model()
+}
+
+///|
+pub fn Viewer::set_value(self : Viewer, value : String) -> Unit {
+ self.widget.set_value(value)
+}
+
+///|
+pub fn Viewer::get_value(self : Viewer) -> String {
+ self.widget.get_value()
+}
+
+///|
+pub fn Viewer::update_options(self : Viewer, options : ViewerOptions) -> Unit {
+ self.options = options
+ self.widget.update_options(options.code_options())
+}
+
+///|
+pub fn Viewer::get_options(self : Viewer) -> ViewerOptions {
+ self.options
+}
+
+///|
+pub fn Viewer::layout(self : Viewer) -> Unit {
+ self.widget.layout()
+}
+
+///|
+pub fn Viewer::focus(self : Viewer) -> Unit {
+ self.widget.focus()
+}
+
+///|
+pub fn Viewer::dispose(self : Viewer) -> Unit {
+ self.widget.dispose()
+}
+
+///|
+pub fn Viewer::handle_initialized(self : Viewer) -> Unit {
+ self.widget.handle_initialized()
+}
+
+///|
+pub fn Viewer::get_id(self : Viewer) -> String {
+ self.widget.get_id()
+}
+
+///|
+pub fn Viewer::get_dom_node(self : Viewer) -> @rdom.Element? {
+ self.widget.get_dom_node()
+}
+
+///|
+pub fn Viewer::get_container_dom_node(self : Viewer) -> @rdom.Element {
+ self.widget.get_container_dom_node()
+}
+
+///|
+pub fn Viewer::has_text_focus(self : Viewer) -> Bool {
+ self.widget.has_text_focus()
+}
+
+///|
+pub fn Viewer::has_widget_focus(self : Viewer) -> Bool {
+ self.widget.has_widget_focus()
+}
+
+///|
+pub fn Viewer::get_editor_type(self : Viewer) -> String {
+ self.widget.get_editor_type()
+}
+
+///|
+pub fn Viewer::on_did_change_model(
+ self : Viewer,
+ listener : (@editor_api.ModelChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_model(listener)
+}
+
+///|
+pub fn Viewer::on_did_change_model_content(
+ self : Viewer,
+ listener : (@model.ModelContentChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_model_content(listener)
+}
+
+///|
+pub fn Viewer::on_did_change_cursor_position(
+ self : Viewer,
+ listener : (@editor_api.CursorPositionChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_cursor_position(listener)
+}
+
+///|
+pub fn Viewer::on_did_change_cursor_selection(
+ self : Viewer,
+ listener : (@editor_api.CursorSelectionChangedEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_cursor_selection(listener)
+}
+
+///|
+pub fn Viewer::on_did_scroll_change(
+ self : Viewer,
+ listener : (@editor_api.ScrollEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_scroll_change(listener)
+}
+
+///|
+pub fn Viewer::on_did_change_view_zones(
+ self : Viewer,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_view_zones(listener)
+}
+
+///|
+pub fn Viewer::on_did_change_hidden_areas(
+ self : Viewer,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_change_hidden_areas(listener)
+}
+
+///|
+pub fn Viewer::on_did_dispose(
+ self : Viewer,
+ listener : () -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_did_dispose(listener)
+}
+
+///|
+pub fn Viewer::on_mouse_down(
+ self : Viewer,
+ listener : (@editor_browser.EditorMouseEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_mouse_down(listener)
+}
+
+///|
+pub fn Viewer::on_mouse_up(
+ self : Viewer,
+ listener : (@editor_browser.EditorMouseEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_mouse_up(listener)
+}
+
+///|
+pub fn Viewer::on_mouse_move(
+ self : Viewer,
+ listener : (@editor_browser.EditorMouseEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_mouse_move(listener)
+}
+
+///|
+pub fn Viewer::on_context_menu(
+ self : Viewer,
+ listener : (@editor_browser.EditorMouseEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_context_menu(listener)
+}
+
+///|
+pub fn Viewer::on_mouse_leave(
+ self : Viewer,
+ listener : (@editor_browser.PartialEditorMouseEvent) -> Unit,
+) -> @base_common.Disposable {
+ self.widget.on_mouse_leave(listener)
+}
+
+///|
+pub fn Viewer::save_view_state(self : Viewer) -> ViewerViewState? {
+ match self.widget.save_view_state() {
+ Some(inner) => Some({ inner, })
+ None => None
+ }
+}
+
+///|
+pub fn Viewer::restore_view_state(
+ self : Viewer,
+ state : ViewerViewState?,
+) -> Unit {
+ match state {
+ Some(state) => self.widget.restore_view_state(Some(state.inner))
+ None => self.widget.restore_view_state(None)
+ }
+}
+
+///|
+pub fn Viewer::fold_top_level(self : Viewer) -> Unit {
+ self.widget.fold_top_level()
+}
+
+///|
+pub fn Viewer::show_references(
+ self : Viewer,
+ position : @base_common.Position,
+ locations : Array[@language.Location],
+) -> Unit {
+ self.widget.show_references(position, locations)
+}
+
+///|
+pub fn Viewer::change_view_zones(
+ self : Viewer,
+ update : (@editor_browser.ViewZoneChangeAccessor) -> Unit raise,
+) -> Unit {
+ self.widget.change_view_zones(update)
+}
+
+///|
+pub fn Viewer::add_overlay_widget(
+ self : Viewer,
+ widget : @editor_browser.OverlayWidget,
+) -> Unit {
+ self.widget.add_overlay_widget(widget)
+}
+
+///|
+pub fn Viewer::remove_overlay_widget(
+ self : Viewer,
+ widget : @editor_browser.OverlayWidget,
+) -> Unit {
+ self.widget.remove_overlay_widget(widget)
+}
+
+///|
+pub fn Viewer::delta_decorations(
+ self : Viewer,
+ old_decorations : Array[String],
+ new_decorations : Array[@model.ModelDeltaDecoration],
+) -> Array[String] {
+ self.widget.delta_decorations(old_decorations, new_decorations)
+}
+
+///|
+pub fn Viewer::remove_decorations(
+ self : Viewer,
+ decoration_ids : Array[String],
+) -> Unit {
+ self.widget.remove_decorations(decoration_ids)
+}
+
+///|
+pub fn Viewer::create_decorations_collection(
+ self : Viewer,
+ decorations? : Array[@model.ModelDeltaDecoration] = [],
+) -> EditorDecorationsCollection {
+ { inner: self.widget.create_decorations_collection(decorations~) }
+}
+
+///|
+pub fn EditorDecorationsCollection::set(
+ self : EditorDecorationsCollection,
+ decorations : Array[@model.ModelDeltaDecoration],
+) -> Array[String] {
+ self.inner.set(decorations)
+}
+
+///|
+pub fn EditorDecorationsCollection::append(
+ self : EditorDecorationsCollection,
+ decorations : Array[@model.ModelDeltaDecoration],
+) -> Array[String] {
+ self.inner.append(decorations)
+}
+
+///|
+pub fn EditorDecorationsCollection::clear(
+ self : EditorDecorationsCollection,
+) -> Unit {
+ self.inner.clear()
+}
+
+///|
+pub fn EditorDecorationsCollection::length(
+ self : EditorDecorationsCollection,
+) -> Int {
+ self.inner.length()
+}
+
+///|
+pub fn EditorDecorationsCollection::get_range(
+ self : EditorDecorationsCollection,
+ index : Int,
+) -> @base_common.Range? {
+ self.inner.get_range(index)
+}
+
+///|
+pub fn EditorDecorationsCollection::get_ranges(
+ self : EditorDecorationsCollection,
+) -> Array[@base_common.Range] {
+ self.inner.get_ranges()
+}
+
+///|
+pub fn Viewer::get_line_decorations(
+ self : Viewer,
+ line_number : Int,
+) -> Array[@model.ModelDecoration] {
+ self.widget.get_line_decorations(line_number)
+}
+
+///|
+pub fn Viewer::get_decorations_in_range(
+ self : Viewer,
+ range : @base_common.Range,
+) -> Array[@model.ModelDecoration] {
+ self.widget.get_decorations_in_range(range)
+}
diff --git a/editor/viewer/viewer_facade_geometry.mbt b/editor/viewer/viewer_facade_geometry.mbt
new file mode 100644
index 000000000..e0a236c4d
--- /dev/null
+++ b/editor/viewer/viewer_facade_geometry.mbt
@@ -0,0 +1,165 @@
+///|
+pub fn Viewer::get_position(self : Viewer) -> @base_common.Position? {
+ self.widget.get_position()
+}
+
+///|
+pub fn Viewer::get_selection(self : Viewer) -> @core.Selection? {
+ self.widget.get_selection()
+}
+
+///|
+pub fn Viewer::get_selections(self : Viewer) -> Array[@core.Selection]? {
+ self.widget.get_selections()
+}
+
+///|
+pub fn Viewer::set_position(
+ self : Viewer,
+ position : @base_common.Position,
+ source? : String = "api",
+) -> Unit {
+ self.widget.set_position(position, source~)
+}
+
+///|
+pub fn Viewer::set_selection(
+ self : Viewer,
+ selection : @core.Selection,
+ source? : String = "api",
+) -> Unit {
+ self.widget.set_selection(selection, source~)
+}
+
+///|
+pub fn Viewer::set_selections(
+ self : Viewer,
+ selections : Array[@core.Selection],
+ source? : String = "api",
+ reason? : @editor_api.CursorChangeReason = NotSet,
+) -> Unit {
+ self.widget.set_selections(selections, source~, reason~)
+}
+
+///|
+pub fn Viewer::get_visible_ranges(self : Viewer) -> Array[@base_common.Range] {
+ self.widget.get_visible_ranges()
+}
+
+///|
+pub fn Viewer::get_content_width(self : Viewer) -> Double {
+ self.widget.get_content_width()
+}
+
+///|
+pub fn Viewer::get_scroll_width(self : Viewer) -> Double {
+ self.widget.get_scroll_width()
+}
+
+///|
+pub fn Viewer::get_scroll_left(self : Viewer) -> Double {
+ self.widget.get_scroll_left()
+}
+
+///|
+pub fn Viewer::get_content_height(self : Viewer) -> Double {
+ self.widget.get_content_height()
+}
+
+///|
+pub fn Viewer::get_scroll_height(self : Viewer) -> Double {
+ self.widget.get_scroll_height()
+}
+
+///|
+pub fn Viewer::get_scroll_top(self : Viewer) -> Double {
+ self.widget.get_scroll_top()
+}
+
+///|
+pub fn Viewer::set_scroll_top(self : Viewer, value : Double) -> Unit {
+ self.widget.set_scroll_top(value)
+}
+
+///|
+pub fn Viewer::set_scroll_left(self : Viewer, value : Double) -> Unit {
+ self.widget.set_scroll_left(value)
+}
+
+///|
+pub fn Viewer::set_scroll_position(
+ self : Viewer,
+ scroll_top? : Double,
+ scroll_left? : Double,
+) -> Unit {
+ self.widget.set_scroll_position(scroll_top?, scroll_left?)
+}
+
+///|
+pub fn Viewer::get_top_for_line_number(
+ self : Viewer,
+ line_number : Int,
+ include_view_zones? : Bool = false,
+) -> Double {
+ self.widget.get_top_for_line_number(line_number, include_view_zones~)
+}
+
+///|
+pub fn Viewer::get_top_for_position(
+ self : Viewer,
+ line_number : Int,
+ column : Int,
+) -> Double {
+ self.widget.get_top_for_position(line_number, column)
+}
+
+///|
+pub fn Viewer::get_bottom_for_line_number(
+ self : Viewer,
+ line_number : Int,
+) -> Double {
+ self.widget.get_bottom_for_line_number(line_number)
+}
+
+///|
+pub fn Viewer::get_line_height_for_position(
+ self : Viewer,
+ position : @base_common.Position,
+) -> Double {
+ self.widget.get_line_height_for_position(position)
+}
+
+///|
+pub fn Viewer::get_visible_column_from_position(
+ self : Viewer,
+ position : @base_common.Position,
+) -> Int {
+ self.widget.get_visible_column_from_position(position)
+}
+
+///|
+pub fn Viewer::get_offset_for_column(
+ self : Viewer,
+ line_number : Int,
+ column : Int,
+) -> Double {
+ self.widget.get_offset_for_column(line_number, column)
+}
+
+///|
+pub fn Viewer::get_width_of_line(self : Viewer, line_number : Int) -> Double {
+ self.widget.get_width_of_line(line_number)
+}
+
+///|
+pub fn Viewer::get_scrolled_visible_position(
+ self : Viewer,
+ position : @base_common.Position,
+) -> @editor_api.ScrolledVisiblePosition? {
+ self.widget.get_scrolled_visible_position(position)
+}
+
+///|
+pub fn Viewer::get_layout_info(self : Viewer) -> @config.EditorLayoutInfo {
+ self.widget.get_layout_info()
+}
diff --git a/editor/viewer/viewer_facade_reveal.mbt b/editor/viewer/viewer_facade_reveal.mbt
new file mode 100644
index 000000000..e740645ce
--- /dev/null
+++ b/editor/viewer/viewer_facade_reveal.mbt
@@ -0,0 +1,137 @@
+///|
+pub fn Viewer::reveal_line(self : Viewer, line_number : Int) -> Unit {
+ self.widget.reveal_line(line_number)
+}
+
+///|
+pub fn Viewer::reveal_line_in_center(self : Viewer, line_number : Int) -> Unit {
+ self.widget.reveal_line_in_center(line_number)
+}
+
+///|
+pub fn Viewer::reveal_line_near_top(self : Viewer, line_number : Int) -> Unit {
+ self.widget.reveal_line_near_top(line_number)
+}
+
+///|
+pub fn Viewer::reveal_line_in_center_if_outside_viewport(
+ self : Viewer,
+ line_number : Int,
+) -> Unit {
+ self.widget.reveal_line_in_center_if_outside_viewport(line_number)
+}
+
+///|
+pub fn Viewer::reveal_lines(
+ self : Viewer,
+ start_line_number : Int,
+ end_line_number : Int,
+) -> Unit {
+ self.widget.reveal_lines(start_line_number, end_line_number)
+}
+
+///|
+pub fn Viewer::reveal_lines_in_center(
+ self : Viewer,
+ start_line_number : Int,
+ end_line_number : Int,
+) -> Unit {
+ self.widget.reveal_lines_in_center(start_line_number, end_line_number)
+}
+
+///|
+pub fn Viewer::reveal_lines_near_top(
+ self : Viewer,
+ start_line_number : Int,
+ end_line_number : Int,
+) -> Unit {
+ self.widget.reveal_lines_near_top(start_line_number, end_line_number)
+}
+
+///|
+pub fn Viewer::reveal_lines_in_center_if_outside_viewport(
+ self : Viewer,
+ start_line_number : Int,
+ end_line_number : Int,
+) -> Unit {
+ self.widget.reveal_lines_in_center_if_outside_viewport(
+ start_line_number, end_line_number,
+ )
+}
+
+///|
+pub fn Viewer::reveal_position(
+ self : Viewer,
+ position : @base_common.Position,
+) -> Unit {
+ self.widget.reveal_position(position)
+}
+
+///|
+pub fn Viewer::reveal_position_in_center(
+ self : Viewer,
+ position : @base_common.Position,
+) -> Unit {
+ self.widget.reveal_position_in_center(position)
+}
+
+///|
+pub fn Viewer::reveal_position_near_top(
+ self : Viewer,
+ position : @base_common.Position,
+) -> Unit {
+ self.widget.reveal_position_near_top(position)
+}
+
+///|
+pub fn Viewer::reveal_position_in_center_if_outside_viewport(
+ self : Viewer,
+ position : @base_common.Position,
+) -> Unit {
+ self.widget.reveal_position_in_center_if_outside_viewport(position)
+}
+
+///|
+pub fn Viewer::reveal_range(self : Viewer, range : @base_common.Range) -> Unit {
+ self.widget.reveal_range(range)
+}
+
+///|
+pub fn Viewer::reveal_range_in_center(
+ self : Viewer,
+ range : @base_common.Range,
+) -> Unit {
+ self.widget.reveal_range_in_center(range)
+}
+
+///|
+pub fn Viewer::reveal_range_in_center_if_outside_viewport(
+ self : Viewer,
+ range : @base_common.Range,
+) -> Unit {
+ self.widget.reveal_range_in_center_if_outside_viewport(range)
+}
+
+///|
+pub fn Viewer::reveal_range_near_top(
+ self : Viewer,
+ range : @base_common.Range,
+) -> Unit {
+ self.widget.reveal_range_near_top(range)
+}
+
+///|
+pub fn Viewer::reveal_range_near_top_if_outside_viewport(
+ self : Viewer,
+ range : @base_common.Range,
+) -> Unit {
+ self.widget.reveal_range_near_top_if_outside_viewport(range)
+}
+
+///|
+pub fn Viewer::reveal_range_at_top(
+ self : Viewer,
+ range : @base_common.Range,
+) -> Unit {
+ self.widget.reveal_range_at_top(range)
+}
diff --git a/editor/viewer/viewer_facade_services.mbt b/editor/viewer/viewer_facade_services.mbt
new file mode 100644
index 000000000..08f9c7373
--- /dev/null
+++ b/editor/viewer/viewer_facade_services.mbt
@@ -0,0 +1,75 @@
+///|
+/// Public service bundle shared by standalone code viewers and host-owned
+/// Markdown viewers. Ownership remains in the single internal bundle so the
+/// façade cannot double-dispose default services.
+struct ViewerServices {
+ inner : @code_widget.CodeEditorServices
+}
+
+///|
+pub fn ViewerServices::ViewerServices(
+ languages? : @languages.LanguageHandle,
+ markers? : @markers.MarkerDecorationsSource,
+ agent_feedback? : @agent_feedback_api.AgentFeedbackHandle,
+ location_opener? : @navigation_api.LocationOpenerHandle,
+ text_model_resolver? : @navigation_api.TextModelResolverHandle,
+ quick_diff? : @quick_diff_api.QuickDiffHandle,
+ log_service? : @log.LogHandle,
+) -> ViewerServices {
+ {
+ inner: CodeEditorServices(
+ languages?,
+ markers?,
+ agent_feedback?,
+ location_opener?,
+ text_model_resolver?,
+ quick_diff?,
+ log_service?,
+ ),
+ }
+}
+
+///|
+pub fn ViewerServices::dispose(self : ViewerServices) -> Unit {
+ self.inner.dispose()
+}
+
+///|
+fn ViewerServices::code_services(
+ self : ViewerServices,
+) -> @code_widget.CodeEditorServices {
+ self.inner
+}
+
+///|
+fn ViewerServices::languages(
+ self : ViewerServices,
+) -> @languages.LanguageHandle {
+ self.inner.languages()
+}
+
+///|
+fn ViewerServices::marker_decorations(
+ self : ViewerServices,
+) -> @markers.MarkerDecorationsHandle {
+ self.inner.marker_decorations()
+}
+
+///|
+fn ViewerServices::location_opener(
+ self : ViewerServices,
+) -> @navigation_api.LocationOpenerHandle? {
+ self.inner.location_opener()
+}
+
+///|
+fn ViewerServices::text_model_resolver(
+ self : ViewerServices,
+) -> @navigation_api.TextModelResolverHandle? {
+ self.inner.text_model_resolver()
+}
+
+///|
+fn ViewerServices::log_service(self : ViewerServices) -> @log.LogHandle {
+ self.inner.log_service()
+}
diff --git a/editor/vscode b/editor/vscode
index b18492a28..07c20d96c 160000
--- a/editor/vscode
+++ b/editor/vscode
@@ -1 +1 @@
-Subproject commit b18492a288de038fbc7643aae6de8247029d11bd
+Subproject commit 07c20d96cf3f2cbc8142ac7079ba9048cf7f6134