diff --git a/desktop/dev/viewer.css b/desktop/dev/viewer.css index 54d9142ff..6b1fc6c80 100644 --- a/desktop/dev/viewer.css +++ b/desktop/dev/viewer.css @@ -24,7 +24,6 @@ @import url("../../editor/viewer/contrib/references/browser/references.css"); @import url("../../editor/viewer/contrib/hover/hover.css"); @import url("../../editor/viewer/contrib/quick_diff/browser/quick_diff.css"); -@import url("../../editor/viewer/browser/unified_diff/unified_diff.css"); @import url("../../editor/viewer/contrib/agent_feedback/browser/agent_feedback.css"); @import url("../../editor/viewer/browser/diagram_viewport/diagram_viewport.css"); @import url("../../editor/viewer/contrib/markdown_comments/browser/markdown_comments.css"); diff --git a/desktop/frontend/dom_helpers.mbt b/desktop/frontend/dom_helpers.mbt index bb58cf5e1..1abca1cd7 100644 --- a/desktop/frontend/dom_helpers.mbt +++ b/desktop/frontend/dom_helpers.mbt @@ -45,11 +45,14 @@ fn host_prefers_dark() -> Bool { /// owns the palette; the command keeps the DOM write out of the pure handler. fn Theme::apply(self : Theme, system_dark : Bool) -> @cmd.Cmd { let value = if self.is_dark(system_dark) { "dark" } else { "light" } - @cmd.custom_cmd(_ => { - if @dom.document().get_document_element() is Some(root) { - root.set_attribute("data-theme", value) - } - }) + @cmd.batch([ + @cmd.custom_cmd(_ => { + if @dom.document().get_document_element() is Some(root) { + root.set_attribute("data-theme", value) + } + }), + @fileeditor.apply_theme(value), + ]) } ///| diff --git a/desktop/frontend/fileeditor/active_editor_surface_wbtest.mbt b/desktop/frontend/fileeditor/active_editor_surface_wbtest.mbt new file mode 100644 index 000000000..af112c1c3 --- /dev/null +++ b/desktop/frontend/fileeditor/active_editor_surface_wbtest.mbt @@ -0,0 +1,24 @@ +///| +test "only the visible editor surface participates in host layout" { + assert_true( + active_editor_surface_from_visibility(true, false, false) + is Some(CodeEditorSurface), + ) + assert_true( + active_editor_surface_from_visibility(false, true, false) + is Some(MarkdownEditorSurface), + ) + assert_true( + active_editor_surface_from_visibility(false, false, true) + is Some(DiffEditorSurface), + ) + assert_true( + active_editor_surface_from_visibility(false, false, false) is None, + ) + // If a host patch briefly overlaps visibility, the product's most specific + // comparison surface wins and parked source surfaces remain untouched. + assert_true( + active_editor_surface_from_visibility(true, true, true) + is Some(DiffEditorSurface), + ) +} diff --git a/desktop/frontend/fileeditor/unified_diff_comment_bridge_wbtest.mbt b/desktop/frontend/fileeditor/diff_comment_bridge_wbtest.mbt similarity index 51% rename from desktop/frontend/fileeditor/unified_diff_comment_bridge_wbtest.mbt rename to desktop/frontend/fileeditor/diff_comment_bridge_wbtest.mbt index 27904c5dc..f3ae03484 100644 --- a/desktop/frontend/fileeditor/unified_diff_comment_bridge_wbtest.mbt +++ b/desktop/frontend/fileeditor/diff_comment_bridge_wbtest.mbt @@ -1,18 +1,16 @@ ///| test "full-diff comments cross the Desktop boundary without losing line identity" { let file = @pathx.Relative("src/main.mbt") - let deletion = unified_diff_comment_message(file, { - line: { - kind: @unified_diff.Deletion, - original_line_number: Some(4), - modified_line_number: None, - text: "obsolete()", - }, - comment: "good removal", - }) + let deletion = diff_comment_message( + file, + original_line_number=Some(4), + modified_line_number=None, + quote="obsolete()", + comment="good removal", + ) assert_true( deletion - is UnifiedDiffCommentAdded( + is DiffCommentAdded( file=@pathx.Relative("src/main.mbt"), original_line_number=Some(4), modified_line_number=None, @@ -22,18 +20,16 @@ test "full-diff comments cross the Desktop boundary without losing line identity feedback_id=None ), ) - let context = unified_diff_comment_message(file, { - line: { - kind: @unified_diff.Context, - original_line_number: Some(8), - modified_line_number: Some(9), - text: "shared()", - }, - comment: "keep this", - }) + let context = diff_comment_message( + file, + original_line_number=Some(8), + modified_line_number=Some(9), + quote="shared()", + comment="keep this", + ) assert_true( context - is UnifiedDiffCommentAdded( + is DiffCommentAdded( original_line_number=Some(8), modified_line_number=Some(9), quote="shared()", @@ -41,23 +37,18 @@ test "full-diff comments cross the Desktop boundary without losing line identity .. ), ) - let original_viewer = unified_diff_comment_message( + let original_editor = diff_comment_message( file, - { - line: { - kind: @unified_diff.Deletion, - original_line_number: Some(12), - modified_line_number: None, - text: "removed()", - }, - comment: "restore this", - }, + original_line_number=Some(12), + modified_line_number=None, + quote="removed()", + comment="restore this", resource=ModelUri("openseek-review-original://workspace/src/main.mbt"), feedback_id="feedback-12", ) assert_true( - original_viewer - is UnifiedDiffCommentAdded( + original_editor + is DiffCommentAdded( original_line_number=Some(12), modified_line_number=None, resource=Some( diff --git a/desktop/frontend/fileeditor/diff_provider_policy_wbtest.mbt b/desktop/frontend/fileeditor/diff_provider_policy_wbtest.mbt new file mode 100644 index 000000000..bb1a6510b --- /dev/null +++ b/desktop/frontend/fileeditor/diff_provider_policy_wbtest.mbt @@ -0,0 +1,90 @@ +///| +test "diff provider forces Core outside .mbt without overwriting preference" { + assert_true( + effective_diff_provider_mode(ReviewDiffTree, true) + is @moon_diff_provider.Tree, + ) + assert_true( + effective_diff_provider_mode(ReviewDiffTree, false) + is @moon_diff_provider.Core, + ) + assert_true(supports_moon_diff("SRC/MAIN.MBT")) + assert_false(supports_moon_diff("src/main.mbti")) +} + +///| +test "desktop diff provider preferences and fallback status are host state" { + let initial = State::empty() + assert_true(initial.review_diff_mode is ReviewDiffCore) + assert_true(initial.review_diff_ignore_comments) + assert_eq(initial.review_diff_status, None) + + let path = @pathx.Relative("src/main.mbt") + let selected = test_modified_change(path) + let docs = owned_state().docs.copy() + docs[path] = { + ..loaded_doc(path.0), + review: Some({ + generation: 1, + working_generation: 1, + baseline: ReviewContent("old source"), + view_mode: ReviewDiff, + selected, + }), + } + let state = { ..owned_state(), docs, } + let ctx = { ..test_ctx(), open_files: [path], active_file: Some(path) } + let (_, tree) = update( + test_emit(), + ReviewDiffModeSelected(ReviewDiffTree), + state, + ctx, + ) + assert_true(tree.review_diff_mode is ReviewDiffTree) + assert_eq(tree.review_diff_status, None) + let (_, include_comments) = update( + test_emit(), + ToggleReviewDiffIgnoreComments, + tree, + ctx, + ) + assert_false(include_comments.review_diff_ignore_comments) + let fallback : ReviewDiffStatus = { + requested: ReviewDiffTree, + effective: ReviewDiffToken, + fell_back: true, + } + let (_, settled) = update( + test_emit(), + ReviewDiffStatusChanged(fallback), + include_comments, + ctx, + ) + assert_eq(settled.review_diff_status, Some(fallback)) +} + +///| +test "non-MoonBit review parks the last Moon provider preference" { + let path = @pathx.Relative("README.md") + let selected = test_modified_change(path) + let docs = owned_state().docs.copy() + docs[path] = { + ..loaded_doc(path.0), + review: Some({ + generation: 1, + working_generation: 1, + baseline: ReviewContent("old text"), + view_mode: ReviewDiff, + selected, + }), + } + let state = { ..owned_state(), docs, review_diff_mode: ReviewDiffTree } + let ctx = { ..test_ctx(), open_files: [path], active_file: Some(path) } + let (_, unchanged) = update( + test_emit(), + ReviewDiffModeSelected(ReviewDiffToken), + state, + ctx, + ) + assert_true(unchanged.review_diff_mode is ReviewDiffTree) +} diff --git a/desktop/frontend/fileeditor/editor_panel.mbt b/desktop/frontend/fileeditor/editor_panel.mbt index 261b2edf4..1625050ad 100644 --- a/desktop/frontend/fileeditor/editor_panel.mbt +++ b/desktop/frontend/fileeditor/editor_panel.mbt @@ -11,10 +11,14 @@ const ViewerHostId = "viewer-host" ///| -/// The stable sibling host reserved for the switchable DiffViewer. The DOM id -/// stays compatible with the former eager unified renderer while the -/// imperative owner and contents migrate in place. -const DiffViewerHostId = "unified-diff-host" +/// The stable sibling host for rich Markdown source presentation. Selection +/// is an explicit desktop policy; the code-only Viewer never changes its own +/// presentation from model metadata. +const MarkdownViewerHostId = "markdown-viewer-host" + +///| +/// The stable sibling host reserved for the switchable DiffEditor. +const DiffEditorHostId = "diff-editor-host" ///| /// A model in this URI scheme is a Git HEAD snapshot, not a document the @@ -33,26 +37,42 @@ const HistoryRevisionScheme = "openseek-git-history" /// keys markers by owner so one source's set replaces cleanly. const DiagnosticsOwner = "moon-lsp" +///| +const DefaultEditorFontFamily = "\"Geist Mono\", \"SF Mono\", ui-monospace, \"Cascadia Mono\", Menlo, Consolas, monospace" + +///| +const DefaultEditorFontSize : Double = 13.0 + ///| /// The viewer's imperative state — the attached widget, its services, and the /// document it currently shows. None of this may live in the Rabbita model /// (the virtual DOM must not own imperative DOM state), so it is collected /// into this one module-level instance instead of loose globals. priv struct ViewerHost { - // The single viewer instance. `None` until the first `request_viewer_mount` - // finds the host element in the DOM — the viewer API constructs attached + // The code-only source Viewer. `None` until the first + // `request_viewer_mount` finds the host element in the DOM — the Viewer API constructs attached // (`Viewer::create`), so creation and mounting are one step. The host is // rendered on every screen from the first paint, and every file read is // batched behind a mount request whose after-render tick precedes the async // reply, so by the time a document arrives the viewer exists. mut viewer : @viewer.Viewer? // Kept separate from the source Viewer so a Diff/Source toggle preserves - // the source model, selection, folding, and scroll state. The comparison - // itself is composed from two ordinary Viewers. - mut diff_viewer : @viewer.DiffViewer? + // the source model, selection, folding, and scroll state. DiffEditor owns + // two code kernels directly rather than wrapping public Viewers. + mut diff_editor : @viewer.DiffEditor? + // Rich Markdown is a separate public surface, never a presentation branch + // inside the code Viewer. It shares product services but owns its own model + // and view-state lifetime. + mut markdown_viewer : @viewer.MarkdownViewer? // Desired full-comparison layout survives a pre-mount command in the same // way font settings do for the source Viewer. - mut diff_render_mode : @viewer.DiffEditorRenderMode + mut diff_options : @viewer.DiffEditorOptions + // Desktop owns MoonBit-specific comparison policy. The provider stays + // outside the reusable editor so non-`.mbt` documents can force Core while + // retaining the user's last Token/Tree preference. + diff_provider : @moon_diff_provider.MoonDocumentDiffProvider + mut diff_mode : ReviewDiffMode + mut diff_ignore_comments : Bool // A positioned transcript link keeps its caret collapsed at the requested // column. This persistent whole-line decoration makes the destination visible // without turning the line into a selection and opening Comment UI. Symbol @@ -85,6 +105,9 @@ priv struct ViewerHost { // Monotonic model version; the viewer treats a new version as a new // document. mut version : Int + // Imperative canvas/Markdown renderers must receive theme changes through + // their options API; CSS data attributes alone cannot repaint their pixels. + mut theme : String // Desired family survives before the Viewer mounts, so startup settings and // a later first file open cannot race back to the editor package default. mut font_family : String @@ -96,13 +119,27 @@ priv struct ViewerHost { // Scroll is DOM state, so it lives here beside the viewer rather than in // the Rabbita model. view_states : Map[String, @viewer.ViewerViewState] + markdown_view_states : Map[String, @viewer.MarkdownViewerViewState] } ///| let viewer_state : ViewerHost = { viewer: None, - diff_viewer: None, - diff_render_mode: @viewer.SideBySide, + diff_editor: None, + markdown_viewer: None, + diff_options: @viewer.DiffEditorOptions( + editor_options=@viewer.ViewerOptions( + theme="light", + soft_wrap=true, + font_family=DefaultEditorFontFamily, + font_size=DefaultEditorFontSize, + ), + layout=@viewer.SideBySide, + automatic_inline=false, + ), + diff_provider: @moon_diff_provider.MoonDocumentDiffProvider(), + diff_mode: ReviewDiffCore, + diff_ignore_comments: true, point_range_highlight: None, search_match_highlights: None, services: None, @@ -110,9 +147,11 @@ let viewer_state : ViewerHost = { relative: None, navigation: None, version: 0, - font_family: "\"Geist Mono\", \"SF Mono\", ui-monospace, \"Cascadia Mono\", Menlo, Consolas, monospace", - font_size: 13.0, + theme: "light", + font_family: DefaultEditorFontFamily, + font_size: DefaultEditorFontSize, view_states: Map([]), + markdown_view_states: Map([]), } ///| @@ -121,6 +160,100 @@ fn mounted_viewer() -> @viewer.Viewer? { viewer_state.viewer } +///| +fn mounted_markdown_viewer() -> @viewer.MarkdownViewer? { + viewer_state.markdown_viewer +} + +///| +fn mounted_diff_editor() -> @viewer.DiffEditor? { + viewer_state.diff_editor +} + +///| +priv enum ActiveEditorSurface { + CodeEditorSurface + MarkdownEditorSurface + DiffEditorSurface +} + +///| +fn active_editor_surface_from_visibility( + code_visible : Bool, + markdown_visible : Bool, + diff_visible : Bool, +) -> ActiveEditorSurface? { + if diff_visible { + Some(DiffEditorSurface) + } else if markdown_visible { + Some(MarkdownEditorSurface) + } else if code_visible { + Some(CodeEditorSurface) + } else { + None + } +} + +///| +fn editor_surface_host_visible(host_id : String) -> Bool { + guard @dom.document().get_element_by_id(host_id).to_option() is Some(host) else { + return false + } + let rect = host.get_bounding_client_rect() + rect.get_width() > 0.0 && rect.get_height() > 0.0 +} + +///| +fn active_editor_surface() -> ActiveEditorSurface? { + active_editor_surface_from_visibility( + editor_surface_host_visible(ViewerHostId), + editor_surface_host_visible(MarkdownViewerHostId), + editor_surface_host_visible(DiffEditorHostId), + ) +} + +///| +fn layout_active_editor_surface() -> Unit { + match active_editor_surface() { + Some(CodeEditorSurface) => + match mounted_viewer() { + Some(viewer) => viewer.layout() + None => () + } + Some(MarkdownEditorSurface) => + match mounted_markdown_viewer() { + Some(viewer) => viewer.layout() + None => () + } + Some(DiffEditorSurface) => + match mounted_diff_editor() { + Some(viewer) => viewer.layout() + None => () + } + None => () + } +} + +///| +/// Resolves feedback for the live working document. The code source Viewer is +/// normally authoritative; when rich Markdown is selected it is detached, so +/// the modified DiffEditor kernel becomes the working-document surface. +fn working_feedback_model( + source_model : @model.TextModel?, + diff_model : @viewer.DiffEditorModel?, + resource : @common.Uri, +) -> @model.TextModel? { + match source_model { + Some(model) if model.uri.to_string() == resource.to_string() => Some(model) + _ => + match diff_model { + Some(model) if model.modified.uri.to_string() == resource.to_string() => + Some(model.modified) + _ => None + } + } +} + ///| /// Register syntax highlighting for the languages the panel can recognize. /// Files of any other type render as plain text. @@ -145,14 +278,95 @@ fn register_tokenizers() -> Unit { install_moonbit_markdown_comment_provider() } +///| +fn ReviewDiffMode::provider_mode( + self : ReviewDiffMode, +) -> @moon_diff_provider.MoonDiffMode { + match self { + ReviewDiffCore => @moon_diff_provider.Core + ReviewDiffToken => @moon_diff_provider.Token + ReviewDiffTree => @moon_diff_provider.Tree + } +} + +///| +fn review_diff_mode(mode : @moon_diff_provider.MoonDiffMode) -> ReviewDiffMode { + match mode { + @moon_diff_provider.Core => ReviewDiffCore + @moon_diff_provider.Token => ReviewDiffToken + @moon_diff_provider.Tree => ReviewDiffTree + } +} + +///| +fn supports_moon_diff(path : String) -> Bool { + path.to_lower().has_suffix(".mbt") +} + +///| +fn uses_markdown_presentation(name : String) -> Bool { + name.to_lower().has_suffix(".md") || language_id(name) == "markdown" +} + +///| +fn markdown_resource_kind(name : String) -> @viewer.MarkdownResourceKind { + if name.to_lower().has_suffix(".mbt.md") { + @viewer.MoonBitMarkdown + } else { + @viewer.OrdinaryMarkdown + } +} + +///| +fn effective_diff_provider_mode( + mode : ReviewDiffMode, + supports_moonbit : Bool, +) -> @moon_diff_provider.MoonDiffMode { + if supports_moonbit { + mode.provider_mode() + } else { + @moon_diff_provider.Core + } +} + +///| +fn configure_diff_provider(supports_moonbit : Bool) -> Unit { + let provider_mode = effective_diff_provider_mode( + viewer_state.diff_mode, + supports_moonbit, + ) + viewer_state.diff_provider.set_ignore_comments( + viewer_state.diff_ignore_comments, + ) + viewer_state.diff_provider.set_mode(provider_mode) +} + +///| +/// Apply desktop-owned provider preferences to the current comparison. The +/// requested Moon mode is retained even when a non-`.mbt` model forces Core. +fn apply_review_diff_provider( + mode : ReviewDiffMode, + ignore_comments : Bool, +) -> @cmd.Cmd { + @cmd.custom_cmd(_scheduler => { + viewer_state.diff_mode = mode + viewer_state.diff_ignore_comments = ignore_comments + let supports_moonbit = viewer_state.diff_editor + .bind(editor => editor.get_model()) + .map(model => supports_moon_diff(model.modified.uri.path)) + .unwrap_or(false) + configure_diff_provider(supports_moonbit) + }) +} + ///| /// Create the viewer in its host element after the next paint, once (the /// tokenizers and hover provider register with it). A no-op if already /// mounted or the host element is not in the DOM yet. /// -/// Mounting also wires both ordinary-Viewer comment flows into the update +/// Mounting also wires both code-pane comment flows into the update /// loop. Working-side feedback becomes `EditorCommentAdded`; feedback filed on -/// the original pane becomes `UnifiedDiffCommentAdded` with original-revision +/// the original pane becomes `DiffCommentAdded` with original-revision /// identity. The main package turns either event into a composer chip. fn request_viewer_mount(dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd { @cmd.custom_cmd( @@ -178,7 +392,7 @@ fn request_viewer_mount(dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd { host, services=services.viewer_services, options=@viewer.ViewerOptions( - theme="light", + theme=viewer_state.theme, placeholder="Select a file to view", font_family=viewer_state.font_family, font_size=viewer_state.font_size, @@ -196,11 +410,18 @@ fn request_viewer_mount(dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd { return } guard viewer_state.relative is Some(file) else { return } - // The modified pane uses the source Viewer's live URI and exact - // content, so both submit the same working-document event and retain - // exact columns. - if viewer.get_model() is Some(model) && - model.uri.to_string() == event.feedback.resource.to_string() { + // The modified pane and code source use the same live URI. Rich + // Markdown detaches that source Viewer, so resolve the modified diff + // model as the fallback working-document owner. + let diff_model = viewer_state.diff_editor.bind(editor => { + editor.get_model() + }) + if working_feedback_model( + viewer.get_model(), + diff_model, + event.feedback.resource, + ) + is Some(model) { scheduler.add( dispatch( EditorCommentAdded( @@ -219,8 +440,7 @@ fn request_viewer_mount(dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd { // host language providers. Their feedback still uses the ordinary // Viewer UI and is projected onto the existing original-side chip // contract, including a key for clearing that Viewer's bubble. - guard viewer_state.diff_viewer is Some(diff_viewer) && - diff_viewer.get_model() is Some(diff_model) && + guard diff_model is Some(diff_model) && diff_model.original.uri.to_string() == event.feedback.resource.to_string() else { return @@ -230,19 +450,14 @@ fn request_viewer_mount(dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd { ) scheduler.add( dispatch( - unified_diff_comment_message( + diff_comment_message( file, - { - line: { - kind: @unified_diff.Deletion, - original_line_number: Some( - event.feedback.range.start_line_number, - ), - modified_line_number: None, - text: quote, - }, - comment: event.feedback.text, - }, + original_line_number=Some( + event.feedback.range.start_line_number, + ), + modified_line_number=None, + quote~, + comment=event.feedback.text, resource=ModelUri(event.feedback.resource.to_string()), feedback_id=event.feedback.id, ), @@ -251,37 +466,104 @@ fn request_viewer_mount(dispatch : @cmd.Emit[Msg]) -> @cmd.Cmd { }) |> ignore } - if viewer_state.diff_viewer is None && + if viewer_state.markdown_viewer is None && viewer_state.services is Some(services) && - @dom.document().get_element_by_id(DiffViewerHostId).to_option() - is Some(diff_host) { - viewer_state.diff_viewer = Some( - @viewer.DiffViewer::create( - diff_host, + @dom.document().get_element_by_id(MarkdownViewerHostId).to_option() + is Some(markdown_host) { + viewer_state.markdown_viewer = Some( + @viewer.MarkdownViewer::create( + markdown_host, services=services.viewer_services, - options=@viewer.ViewerOptions(theme="light"), - render_mode=viewer_state.diff_render_mode, + options=@viewer.MarkdownViewerOptions( + theme=viewer_state.theme, + placeholder="Select a Markdown file to view", + ), ), ) } + if viewer_state.diff_editor is None && + viewer_state.services is Some(services) && + @dom.document().get_element_by_id(DiffEditorHostId).to_option() + is Some(diff_host) { + let diff_editor = @viewer.DiffEditor::create( + diff_host, + services=services.viewer_services, + options=viewer_state.diff_options, + provider=viewer_state.diff_provider, + ) + diff_editor.on_did_update_diff(() => { + let status = viewer_state.diff_provider.get_status() + scheduler.add( + dispatch( + ReviewDiffStatusChanged({ + requested: review_diff_mode(status.requested), + effective: review_diff_mode(status.effective), + fell_back: status.fell_back, + }), + ), + ) + }) + |> ignore + viewer_state.diff_editor = Some(diff_editor) + } }, kind=@cmd.after_render, ) } ///| -/// Retain and apply the panel's split/unified preference without replacing the -/// comparison models or either ordinary child Viewer. +/// Retain and apply the panel's side-by-side/inline preference without +/// replacing the comparison models or either code kernel. fn apply_review_diff_layout(layout : ReviewDiffLayout) -> @cmd.Cmd { @cmd.custom_cmd(_scheduler => { - let render_mode = match layout { + let editor_layout = match layout { ReviewSideBySide => @viewer.SideBySide - ReviewUnified => @viewer.Unified + ReviewInline => @viewer.Inline + } + let options = viewer_state.diff_options.with_layout(editor_layout) + viewer_state.diff_options = options + if viewer_state.diff_editor is Some(diff_editor) { + diff_editor.update_options(options) + } + }) +} + +///| +fn current_diff_pane_options() -> @viewer.ViewerOptions { + @viewer.ViewerOptions( + theme=viewer_state.theme, + soft_wrap=true, + font_family=viewer_state.font_family, + font_size=viewer_state.font_size, + ) +} + +///| +fn refresh_diff_pane_options() -> Unit { + let options = viewer_state.diff_options.with_editor_options( + current_diff_pane_options(), + ) + viewer_state.diff_options = options + if viewer_state.diff_editor is Some(diff_editor) { + diff_editor.update_options(options) + } +} + +///| +/// Update every imperative presentation, including canvas overview markers +/// and Markdown diagrams that cannot repaint from inherited CSS alone. +pub fn apply_theme(theme : String) -> @cmd.Cmd { + @cmd.custom_cmd(_scheduler => { + viewer_state.theme = theme + if viewer_state.viewer is Some(viewer) { + viewer.update_options(viewer.get_options().with_theme(theme)) } - viewer_state.diff_render_mode = render_mode - if viewer_state.diff_viewer is Some(diff_viewer) { - diff_viewer.set_render_mode(render_mode) + if viewer_state.markdown_viewer is Some(markdown_viewer) { + markdown_viewer.update_options( + markdown_viewer.get_options().with_theme(theme), + ) } + refresh_diff_pane_options() }) } @@ -295,6 +577,7 @@ pub fn apply_font_family(font_family : String) -> @cmd.Cmd { if viewer_state.viewer is Some(viewer) { viewer.update_options(viewer.get_options().with_font_family(font_family)) } + refresh_diff_pane_options() }) } @@ -308,6 +591,7 @@ pub fn apply_font_size(font_size : Double) -> @cmd.Cmd { if viewer_state.viewer is Some(viewer) { viewer.update_options(viewer.get_options().with_font_size(font_size)) } + refresh_diff_pane_options() }) } @@ -315,18 +599,21 @@ pub fn apply_font_size(font_size : Double) -> @cmd.Cmd { /// Keep the renderer-to-application mapping explicit and independently /// testable. No DOM details cross this boundary; both line-number domains are /// retained for the main update loop to interpret. -fn unified_diff_comment_message( +fn diff_comment_message( file : @pathx.Relative, - submission : @unified_diff.UnifiedDiffLineComment, + original_line_number~ : Int?, + modified_line_number~ : Int?, + quote~ : String, + comment~ : String, resource? : ModelUri, feedback_id? : String, ) -> Msg { - UnifiedDiffCommentAdded( + DiffCommentAdded( file~, - original_line_number=submission.line.original_line_number, - modified_line_number=submission.line.modified_line_number, - quote=submission.line.text, - comment=submission.comment, + original_line_number~, + modified_line_number~, + quote~, + comment~, resource~, feedback_id~, ) @@ -357,14 +644,7 @@ pub fn remove_editor_feedback( /// changes the panel's host size without any panel message. pub fn request_viewer_remeasure() -> @cmd.Cmd { @cmd.custom_cmd( - _after => { - if viewer_state.viewer is Some(viewer) { - viewer.layout() - } - if viewer_state.diff_viewer is Some(diff_viewer) { - diff_viewer.layout() - } - }, + _after => layout_active_editor_surface(), kind=@cmd.after_render, ) } @@ -395,19 +675,14 @@ fn original_revision_model_uri( } ///| -/// Dispose a pair only after every Viewer render already queued by the swap has -/// had first chance to finish. `Viewer::set_model` synchronously detaches the -/// outgoing presentation but its replacement paints on the animation-frame -/// coordinator; retiring the TextModels in that same command can otherwise -/// race token, hover, feedback, or render work that still holds the old pair. -fn retire_diff_comparison_models(previous : @viewer.DiffEditorModel) -> Unit { - @base_browser.schedule_at_next_animation_frame(() => { - previous.original.dispose() - if !previous.modified.same(previous.original) { - previous.modified.dispose() - } - }) - |> ignore +/// Dispose a pair returned by `DiffEditor::set_model`. The return contract +/// guarantees both kernels and the shared view model have already detached +/// it, so no frame-delayed retirement or compatibility grace period exists. +fn dispose_diff_comparison_models(previous : @viewer.DiffEditorModel) -> Unit { + previous.original.dispose() + if !previous.modified.same(previous.original) { + previous.modified.dispose() + } } ///| @@ -416,15 +691,14 @@ fn retire_diff_comparison_models(previous : @viewer.DiffEditorModel) -> Unit { /// this: the last complete pair stays parked behind the inactive surface until /// its successor is ready. Hard owner/workspace boundaries clear it here. fn clear_diff_comparison() -> Unit { - guard viewer_state.diff_viewer is Some(diff_viewer) else { return } - let previous = diff_viewer.get_model() - diff_viewer.clear() + guard viewer_state.diff_editor is Some(diff_editor) else { return } + let previous = diff_editor.set_model(None) if previous is Some(previous) { if viewer_state.services is Some(services) { services.feedback.set_feedback_enabled(previous.original.uri, false) services.feedback.clear_feedback(previous.original.uri) } - retire_diff_comparison_models(previous) + dispose_diff_comparison_models(previous) } } @@ -441,13 +715,13 @@ fn show_diff_comparison( original_content : String, modified_content : String, ) -> Unit { - guard viewer_state.diff_viewer is Some(diff_viewer) && + guard viewer_state.diff_editor is Some(diff_editor) && viewer_state.services is Some(services) && original_revision_model_uri(owner, absolute, relative, name) is Some(original_uri) else { return } - if diff_viewer.get_model() is Some(current) && + if diff_editor.get_model() is Some(current) && current.original.uri.to_string() == original_uri.to_string() && current.modified.uri.to_string() == modified_uri.to_string() && current.original.get_value() == original_content && @@ -455,8 +729,8 @@ fn show_diff_comparison( services.feedback.set_feedback_enabled(original_uri, relative is Some(_)) return } - let previous = diff_viewer.get_model() - if previous is Some(previous) { + let current = diff_editor.get_model() + if current is Some(previous) { services.feedback.set_feedback_enabled(previous.original.uri, false) services.feedback.clear_feedback(previous.original.uri) } @@ -478,12 +752,13 @@ fn show_diff_comparison( "diff-v" + model_version.to_string(), modified_content, ) - diff_viewer.set_model( + configure_diff_provider(supports_moon_diff(name)) + let previous = diff_editor.set_model( Some(@viewer.DiffEditorModel(original_model, modified_model)), ) services.feedback.set_feedback_enabled(original_uri, relative is Some(_)) if previous is Some(previous) { - retire_diff_comparison_models(previous) + dispose_diff_comparison_models(previous) } } @@ -505,7 +780,7 @@ fn history_revision_model_uri( } ///| -/// Install a historical pair directly into the permanent DiffViewer. Neither +/// Install a historical pair directly into the permanent DiffEditor. Neither /// model represents the working tree, so feedback, quick diff, diagnostics, /// and language navigation stay disabled on both sides. fn show_history_diff_in_viewer( @@ -515,7 +790,7 @@ fn show_history_diff_in_viewer( modified_content : String, ) -> @cmd.Cmd { let show = @cmd.custom_cmd(_scheduler => { - guard viewer_state.diff_viewer is Some(diff_viewer) && + guard viewer_state.diff_editor is Some(diff_editor) && viewer_state.services is Some(services) else { return } @@ -529,7 +804,7 @@ fn show_history_diff_in_viewer( is Some(modified_uri) else { return } - if diff_viewer.get_model() is Some(current) && + if diff_editor.get_model() is Some(current) && current.original.uri.to_string() == original_uri.to_string() && current.modified.uri.to_string() == modified_uri.to_string() && current.original.get_value() == original_content && @@ -538,8 +813,8 @@ fn show_history_diff_in_viewer( services.feedback.set_feedback_enabled(modified_uri, false) return } - let previous = diff_viewer.get_model() - if previous is Some(previous) { + let current = diff_editor.get_model() + if current is Some(previous) { services.feedback.set_feedback_enabled(previous.original.uri, false) services.feedback.set_feedback_enabled(previous.modified.uri, false) services.feedback.clear_feedback(previous.original.uri) @@ -565,7 +840,8 @@ fn show_history_diff_in_viewer( "history-modified-v" + version.to_string(), modified_content, ) - diff_viewer.set_model( + configure_diff_provider(supports_moon_diff(diff.path.0)) + let previous = diff_editor.set_model( Some(@viewer.DiffEditorModel(original_model, modified_model)), ) services.feedback.set_feedback_enabled(original_uri, false) @@ -574,7 +850,7 @@ fn show_history_diff_in_viewer( viewer_state.relative = None viewer_state.navigation = None if previous is Some(previous) { - retire_diff_comparison_models(previous) + dispose_diff_comparison_models(previous) } }) @cmd.batch([show, request_viewer_remeasure()]) @@ -693,6 +969,19 @@ fn show_transient_navigation_target_highlight( |> ignore } +///| +fn dispose_markdown_model(model : @viewer.MarkdownViewerModel) -> Unit { + model.model.dispose() +} + +///| +fn dispose_source_model(model : @model.TextModel?) -> Unit { + match model { + Some(model) => model.dispose() + None => () + } +} + ///| /// Show a file's contents in the viewer as a fresh read-only document. A /// symbol jump's `range`, when present, is revealed centered once the @@ -707,12 +996,12 @@ fn show_transient_navigation_target_highlight( /// brings the incoming document back to its saved position instead of the /// top — the Monaco save/restore-view-state contract around `set_model`. /// `review_original`, when present, is the Git HEAD baseline used by the -/// Viewer's quick-diff contribution. An empty string is meaningful for a new -/// file; omission clears any earlier baseline for this resource. A unified -/// pair, when present, is rendered by the sibling switchable DiffViewer while -/// the source Viewer remains mounted. Every show is followed by an after-render -/// layout pass: either sibling host may have just left `display: none`, where -/// its child Viewer could only measure Monaco's 5px minimum. +/// code Viewer's quick-diff contribution. An empty string is meaningful for a +/// new file; omission clears any earlier baseline for this resource. A +/// comparison pair, when present, is rendered by the sibling DiffEditor while +/// both source widgets remain mounted. Every show is followed by an +/// after-render layout pass: a sibling host may have just left `display: none`, +/// where its child widget could only measure Monaco's 5px minimum. fn show_file_in_viewer( owner : @interop.ConversationKey, root~ : @common.Uri?, @@ -724,14 +1013,18 @@ fn show_file_in_viewer( annotation? : String, restore_scroll? : Bool = false, review_original? : String, - unified_diff? : (String, String), + diff? : (String, String), search_highlights? : @grep_search.EditorHighlights, ) -> @cmd.Cmd { let show = @cmd.custom_cmd(_scheduler => { // Reads are always batched behind a mount request whose after-render tick // runs before this async reply, so the viewer exists here (see // `ViewerHost::viewer`); the guard only drops a pathological stray. - guard viewer_state.viewer is Some(viewer) else { return } + guard viewer_state.viewer is Some(viewer) && + viewer_state.markdown_viewer is Some(markdown_viewer) else { + return + } + let show_markdown = uses_markdown_presentation(name) // The URI is the file's absolute path and host identity. The hover // provider pairs it with the active navigation root. A notice document // (withheld binary/oversized file) has no file behind it, so its inert @@ -756,10 +1049,22 @@ fn show_file_in_viewer( // unchanged, or a jump into the file on screen), the model is left // untouched so the selection, scroll, and feedback bubbles survive; a // jump's reveal and annotation still apply below, over the kept document. - let same_document = viewer.get_model() is Some(model) && - model.uri.to_string() == uri.to_string() && - model.get_value() == content - let previous_uri = viewer.get_model().map(model => model.uri) + let code_model = viewer.get_model() + let markdown_model = markdown_viewer.get_model() + let same_document = if show_markdown { + markdown_model is Some(current) && + current.model.uri.to_string() == uri.to_string() && + current.model.get_value() == content && + current.resource_kind == markdown_resource_kind(name) + } else { + code_model is Some(current) && + current.uri.to_string() == uri.to_string() && + current.get_value() == content + } + let previous_uri = match code_model { + Some(model) => Some(model.uri) + None => markdown_model.map(model => model.model.uri) + } let restore_view_state = should_restore_view_state( restore_scroll, previous_uri, uri, ) @@ -774,10 +1079,14 @@ fn show_file_in_viewer( } if !same_document { // Park the outgoing document's scroll position for its tab's return. - if viewer_state.relative is Some(prev) && - viewer.get_model() is Some(_) && - viewer.save_view_state() is Some(view_state) { - viewer_state.view_states[prev.0] = view_state + if viewer_state.relative is Some(prev) { + if code_model is Some(_) && viewer.save_view_state() is Some(view_state) { + viewer_state.view_states[prev.0] = view_state + } + if markdown_model is Some(_) && + markdown_viewer.save_view_state() is Some(view_state) { + viewer_state.markdown_view_states[prev.0] = view_state + } } viewer_state.version = viewer_state.version + 1 let version = viewer_state.version @@ -789,22 +1098,59 @@ fn show_file_in_viewer( "v" + version.to_string(), content, ) - // `set_model` resets the view state itself (the Monaco contract), so a - // fresh document always opens at the top; a tab switch or reload then - // restores the saved position over that reset. - viewer.set_model(Some(text_model)) - if restore_view_state && - relative is Some(rel) && - viewer_state.view_states.get(rel.0) is Some(saved) { - viewer.restore_view_state(Some(saved)) + if show_markdown { + // The public surfaces are mutually exclusive model owners. Detach and + // retire Code before installing the rich Markdown model. + viewer.set_model(None) + dispose_source_model(code_model) + let previous = markdown_viewer.set_model( + Some( + @viewer.MarkdownViewerModel( + text_model, + markdown_resource_kind(name), + ), + ), + ) + match previous { + Some(previous) => dispose_markdown_model(previous) + None => () + } + if restore_view_state && + relative is Some(rel) && + viewer_state.markdown_view_states.get(rel.0) is Some(saved) { + markdown_viewer.restore_view_state(Some(saved)) + } + } else { + // `MarkdownViewer::set_model` returns only after its attachment and + // bridges are detached, so the caller-owned TextModel can be retired + // immediately before Code takes over. + match markdown_viewer.set_model(None) { + Some(previous) => dispose_markdown_model(previous) + None => () + } + viewer.set_model(Some(text_model)) + dispose_source_model(code_model) + if restore_view_state && + relative is Some(rel) && + viewer_state.view_states.get(rel.0) is Some(saved) { + viewer.restore_view_state(Some(saved)) + } + // Match the reference workbench's host policy: ordinary `.mbt` files + // open as a top-level outline. `set_model` computes folding ranges + // synchronously, so this is stable initialization state rather than a + // delayed render effect. Keeping it in the fresh-model branch leaves + // folding interactions on the current model user-controlled. + if should_auto_fold_top_level(text_model) { + viewer.fold_top_level() + } } - // Match the reference workbench's host policy: ordinary `.mbt` files - // open as a top-level outline. `set_model` computes folding ranges - // synchronously, so this is stable initialization state rather than a - // delayed render effect. Keeping it in the fresh-model branch leaves - // folding interactions on the current model entirely user-controlled. - if should_auto_fold_top_level(text_model) { - viewer.fold_top_level() + } else if show_markdown { + viewer.set_model(None) + dispose_source_model(code_model) + } else { + match markdown_viewer.set_model(None) { + Some(previous) => dispose_markdown_model(previous) + None => () } } // Gate incoming diagnostics to the file now on screen (a notice document, @@ -830,9 +1176,19 @@ fn show_file_in_viewer( // The service is resource-keyed, so setting this after `set_model` also // handles a late HEAD reply for the document already on screen. The // service event makes the gutter recompute without replacing the model. - services.quick_diff.set_original_content(uri, review_original) + services.quick_diff.set_original_content( + uri, + if show_markdown { + None + } else { + review_original + }, + ) + // MarkdownViewer has no code-feedback contribution, but the same live + // resource may be mounted in DiffEditor's modified code kernel. Keep the + // resource enabled so review comments remain available there. services.feedback.set_feedback_enabled(uri, absolute is Some(_)) - if absolute is Some(_) { + if absolute is Some(_) && !show_markdown { // A fresh document view starts with no bubbles (comments become // chips the moment they are filed, so anything lingering here is a // previous jump's re-display); an annotation chip's jump then @@ -856,7 +1212,7 @@ fn show_file_in_viewer( } } } - match unified_diff { + match diff { Some((original, modified)) => show_diff_comparison( owner, name, uri, absolute, relative, original, modified, @@ -867,28 +1223,38 @@ fn show_file_in_viewer( // avoids a blank repaint and makes View file a non-destructive toggle. None => () } - set_search_match_highlights(search_highlights) + set_search_match_highlights( + if show_markdown { + None + } else { + search_highlights + }, + ) if range is Some(range) { - let target = navigation_target_presentation(range) - viewer.set_position(target.position) - let search_match = match search_highlights { - Some(highlights) => highlights.ranges().contains(range) - None => false - } - if search_match { - () - } else if target.is_transient { - if viewer.get_model() is Some(target_model) { - show_transient_navigation_target_highlight( - viewer, - target_model, - target.decoration, - ) + if show_markdown { + markdown_viewer.reveal_range_in_center(range) + } else { + let target = navigation_target_presentation(range) + viewer.set_position(target.position) + let search_match = match search_highlights { + Some(highlights) => highlights.ranges().contains(range) + None => false } - } else if viewer_state.point_range_highlight is Some(highlight) { - highlight.set([target.decoration]) |> ignore + if search_match { + () + } else if target.is_transient { + if viewer.get_model() is Some(target_model) { + show_transient_navigation_target_highlight( + viewer, + target_model, + target.decoration, + ) + } + } else if viewer_state.point_range_highlight is Some(highlight) { + highlight.set([target.decoration]) |> ignore + } + viewer.reveal_range_in_center(range) } - viewer.reveal_range_in_center(range) } }) @cmd.batch([show, request_viewer_remeasure()]) @@ -942,6 +1308,7 @@ fn should_auto_fold_top_level(text_model : @model.TextModel) -> Bool { fn clear_viewer() -> @cmd.Cmd { @cmd.custom_cmd(_scheduler => { viewer_state.view_states.clear() + viewer_state.markdown_view_states.clear() if viewer_state.viewer is Some(viewer) { if viewer_state.point_range_highlight is Some(highlight) { highlight.clear() @@ -953,17 +1320,25 @@ fn clear_viewer() -> @cmd.Cmd { viewer.get_model() is Some(model) { services.quick_diff.set_original_content(model.uri, None) } - viewer_state.absolute = None - viewer_state.relative = None - viewer_state.navigation = None + let previous = viewer.get_model() viewer.set_model(None) + dispose_source_model(previous) + } + if viewer_state.markdown_viewer is Some(markdown_viewer) { + match markdown_viewer.set_model(None) { + Some(previous) => dispose_markdown_model(previous) + None => () + } } + viewer_state.absolute = None + viewer_state.relative = None + viewer_state.navigation = None clear_diff_comparison() }) } ///| -/// Show the source Viewer's placeholder without touching the other tabs' +/// Clear the active source presentation without touching the other tabs' /// parked scroll positions. The comparison remains parked: transient review /// reads, unavailable files, ordinary navigation, and View file all hide it by /// CSS, while the next complete comparison replaces it atomically. Its memory @@ -981,11 +1356,19 @@ fn clear_document() -> @cmd.Cmd { viewer.get_model() is Some(model) { services.quick_diff.set_original_content(model.uri, None) } - viewer_state.absolute = None - viewer_state.relative = None - viewer_state.navigation = None + let previous = viewer.get_model() viewer.set_model(None) + dispose_source_model(previous) + } + if viewer_state.markdown_viewer is Some(markdown_viewer) { + match markdown_viewer.set_model(None) { + Some(previous) => dispose_markdown_model(previous) + None => () + } } + viewer_state.absolute = None + viewer_state.relative = None + viewer_state.navigation = None }) } @@ -994,7 +1377,10 @@ fn clear_document() -> @cmd.Cmd { /// neighbor's `show_file_in_viewer` (which re-parks the outgoing document, /// closed tab included), so the closed tab's entry does not linger. fn forget_view_state(path : @pathx.Relative) -> @cmd.Cmd { - @cmd.custom_cmd(_scheduler => viewer_state.view_states.remove(path.0)) + @cmd.custom_cmd(_scheduler => { + viewer_state.view_states.remove(path.0) + viewer_state.markdown_view_states.remove(path.0) + }) } ///| @@ -1041,6 +1427,8 @@ fn language_id(name : String) -> String { "javascript" } else if lower.has_suffix(".json") { "json" + } else if lower.has_suffix(".md") { + "markdown" } else { "plaintext" } diff --git a/desktop/frontend/fileeditor/editor_resize.mbt b/desktop/frontend/fileeditor/editor_resize.mbt index fbd13b00f..8119fcb6f 100644 --- a/desktop/frontend/fileeditor/editor_resize.mbt +++ b/desktop/frontend/fileeditor/editor_resize.mbt @@ -129,9 +129,7 @@ fn end_drag(active : Ref[Bool]) -> Unit { guard active.val else { return } active.val = false set_root_class("") - if mounted_viewer() is Some(viewer) { - viewer.layout() - } + layout_active_editor_surface() } ///| @@ -210,15 +208,18 @@ fn document_element() -> @dom.Element? { /// flood of `mousemove`s does not thrash its layout (the viewer fills its host /// with absolutely-positioned layers and cannot see a CSS width change itself). fn schedule_remeasure() -> Unit { - if remeasure_pending.val || mounted_viewer() is None { + if remeasure_pending.val || + ( + mounted_viewer() is None && + mounted_markdown_viewer() is None && + mounted_diff_editor() is None + ) { return } remeasure_pending.val = true @dom.window().request_animation_frame(_ => { remeasure_pending.val = false - if mounted_viewer() is Some(viewer) { - viewer.layout() - } + layout_active_editor_surface() }) |> ignore } diff --git a/desktop/frontend/fileeditor/git_history.mbt b/desktop/frontend/fileeditor/git_history.mbt index 83fcb0216..e1624da1a 100644 --- a/desktop/frontend/fileeditor/git_history.mbt +++ b/desktop/frontend/fileeditor/git_history.mbt @@ -1,6 +1,6 @@ // Git Graph and immutable historical comparisons. Repository plumbing stays // in the native host; this package owns request fencing, graph presentation -// state, and the inert DiffViewer models selected from the Review sidebar. +// state, and the inert DiffEditor models selected from the Review sidebar. ///| const GitHistoryPageSize = 50 diff --git a/desktop/frontend/fileeditor/git_history_wbtest.mbt b/desktop/frontend/fileeditor/git_history_wbtest.mbt index 8ac48a2de..0edd80b2f 100644 --- a/desktop/frontend/fileeditor/git_history_wbtest.mbt +++ b/desktop/frontend/fileeditor/git_history_wbtest.mbt @@ -191,12 +191,12 @@ test "historical tabs use only the inert diff surface and history breadcrumb" { ), ) assert_true( - body.contains("id=\"unified-diff-host\" class=\"viewer-host editor-shell\""), + body.contains("id=\"diff-editor-host\" class=\"viewer-host editor-shell\""), ) let breadcrumb = render_review_html(breadcrumb_view(test_emit(), state, ctx)) assert_true(breadcrumb.contains("11111111")) assert_true(breadcrumb.contains("src/main.mbt")) - assert_true(breadcrumb.contains("aria-label=\"Unified diff layout\"")) + assert_true(breadcrumb.contains("aria-label=\"Inline diff layout\"")) assert_false(breadcrumb.contains("Mark reviewed")) assert_false(breadcrumb.contains("View file")) } diff --git a/desktop/frontend/fileeditor/markdown_presentation_wbtest.mbt b/desktop/frontend/fileeditor/markdown_presentation_wbtest.mbt new file mode 100644 index 000000000..d626c1d5e --- /dev/null +++ b/desktop/frontend/fileeditor/markdown_presentation_wbtest.mbt @@ -0,0 +1,53 @@ +///| +test "desktop selects Markdown presentation and resource kind explicitly" { + assert_true(uses_markdown_presentation("README.md")) + assert_true(uses_markdown_presentation("Guide.MD")) + assert_true(uses_markdown_presentation("tour.mbt.md")) + assert_false(uses_markdown_presentation("main.mbt")) + assert_eq(language_id("README.md"), "markdown") + assert_eq(language_id("tour.mbt.md"), "moonbit") + assert_true(markdown_resource_kind("README.md") is @viewer.OrdinaryMarkdown) + assert_true(markdown_resource_kind("tour.MBT.MD") is @viewer.MoonBitMarkdown) +} + +///| +fn feedback_test_model( + scheme : String, + path : String, + content : String, +) -> @model.TextModel { + @model.TextModel( + try! @common.Uri::from(scheme~, path~), + path, + "markdown", + 1, + "feedback-test", + content, + ) +} + +///| +test "Markdown source feedback falls back to the modified diff kernel" { + let original = feedback_test_model("review-original", "/README.md", "old") + let modified = feedback_test_model("file", "/README.md", "new") + let unrelated = feedback_test_model("file", "/other.md", "other") + defer original.dispose() + defer modified.dispose() + defer unrelated.dispose() + let pair = @viewer.DiffEditorModel(original, modified) + + assert_true( + working_feedback_model(None, Some(pair), modified.uri) is Some(model) && + model.same(modified), + ) + assert_true( + working_feedback_model(Some(unrelated), Some(pair), modified.uri) + is Some(model) && + model.same(modified), + ) + assert_true( + working_feedback_model(Some(unrelated), Some(pair), unrelated.uri) + is Some(model) && + model.same(unrelated), + ) +} diff --git a/desktop/frontend/fileeditor/moon.pkg b/desktop/frontend/fileeditor/moon.pkg index ee032cab9..7ed1b40d6 100644 --- a/desktop/frontend/fileeditor/moon.pkg +++ b/desktop/frontend/fileeditor/moon.pkg @@ -8,6 +8,7 @@ import { "moonbitlang/editor/base/browser" @base_browser, "moonbitlang/editor/base/common", "moonbitlang/editor/language", + "moonbitlang/editor/viewer/diff_provider/moondiff" @moon_diff_provider, "moonbitlang/editor/viewer", "moonbitlang/editor/viewer/common/agent_feedback_api", "moonbitlang/editor/viewer/common/languages", @@ -15,7 +16,6 @@ import { "moonbitlang/editor/viewer/common/model", "moonbitlang/editor/viewer/common/navigation_api", "moonbitlang/editor/viewer/common/quick_diff_api", - "moonbitlang/editor/viewer/common/unified_diff", "moonbitlang/editor/syntax/lang_moonbit", "moonbitlang/editor/syntax/lang_javascript", "moonbitlang/editor/syntax/lang_json", diff --git a/desktop/frontend/fileeditor/pkg.generated.mbti b/desktop/frontend/fileeditor/pkg.generated.mbti index 4d35bbaf2..3bb6a1768 100644 --- a/desktop/frontend/fileeditor/pkg.generated.mbti +++ b/desktop/frontend/fileeditor/pkg.generated.mbti @@ -5,7 +5,9 @@ import { "moonbit-community/rabbita/cmd", "moonbit-community/rabbita/html", "moonbit-community/rabbita/sub", + "moonbitlang/core/debug", "moonbitlang/editor/base/common", + "openseek_desktop/frontend/dock", "openseek_desktop/frontend/grep_search", "openseek_desktop/frontend/interop", "openseek_desktop/frontend/pathx", @@ -15,6 +17,8 @@ import { // Values pub fn activate(@cmd.Emit[Msg], State, Ctx, @pathx.Relative) -> (@cmd.Cmd, State) +pub fn activate_history_diff(@cmd.Emit[Msg], State, Ctx, @dock.GitHistoryDiff) -> (@cmd.Cmd, State) + pub fn apply_font_family(String) -> @cmd.Cmd pub fn apply_font_size(Double) -> @cmd.Cmd @@ -27,6 +31,8 @@ pub fn breadcrumb_view(@cmd.Emit[Msg], State, Ctx) -> @html.Html pub fn close_document(@cmd.Emit[Msg], State, Ctx, @pathx.Relative, next~ : @pathx.Relative?, was_active~ : Bool) -> (@cmd.Cmd, State) +pub fn close_history_diff(State, @dock.GitHistoryDiff) -> State + pub fn editor_classes(State, Ctx) -> String pub fn mount_cmds(@cmd.Emit[Msg]) -> @cmd.Cmd @@ -37,6 +43,8 @@ pub fn open_document(@cmd.Emit[Msg], State, Ctx, @pathx.Relative, name~ : String pub fn open_file(@cmd.Emit[Msg], State, Ctx, @pathx.Relative, file_link_generation? : Int, range~ : @common.Range?, annotation? : String, had_active~ : Bool) -> (@cmd.Cmd, State) +pub fn open_history_diff(@cmd.Emit[Msg], State, Ctx, @dock.GitHistoryDiff) -> (@cmd.Cmd, State) + pub fn panel_toggle_cmds(@cmd.Emit[Msg], State, Ctx, opening~ : Bool) -> (@cmd.Cmd, State) pub fn remove_editor_feedback(ModelUri, String) -> @cmd.Cmd @@ -67,6 +75,8 @@ pub(all) enum ChangeKind { ChangeSymlink ChangeUnknown } derive(Eq) +pub fn ChangeKind::equal(Self, Self) -> Bool +pub fn ChangeKind::not_equal(Self, Self) -> Bool pub(all) enum ChangeList { ChangeListIdle @@ -74,6 +84,8 @@ pub(all) enum ChangeList { ChangeListFailed(String) ChangeListReady(repository~ : Bool, files~ : Array[ChangedFile], refreshing~ : Bool, refresh_again~ : Bool) } derive(Eq) +pub fn ChangeList::equal(Self, Self) -> Bool +pub fn ChangeList::not_equal(Self, Self) -> Bool pub(all) struct ChangedFile { path : @pathx.Relative @@ -82,6 +94,8 @@ pub(all) struct ChangedFile { worktree_status : String kind : ChangeKind } derive(Eq) +pub fn ChangedFile::equal(Self, Self) -> Bool +pub fn ChangedFile::not_equal(Self, Self) -> Bool pub(all) struct Ctx { owner : @interop.ConversationKey @@ -94,7 +108,11 @@ pub(all) struct Ctx { panel_open : Bool open_files : Array[@pathx.Relative] active_file : @pathx.Relative? + open_history_diffs : Array[@dock.GitHistoryDiff] + active_history_diff : @dock.GitHistoryDiff? } derive(Eq) +pub fn Ctx::equal(Self, Self) -> Bool +pub fn Ctx::not_equal(Self, Self) -> Bool pub(all) struct Doc { name : String @@ -102,12 +120,16 @@ pub(all) struct Doc { review : ReviewState? stale : Bool } derive(Eq) +pub fn Doc::equal(Self, Self) -> Bool +pub fn Doc::not_equal(Self, Self) -> Bool pub(all) enum ExplorerMode { ExplorerFiles ExplorerChanges ExplorerSearch } derive(Eq) +pub fn ExplorerMode::equal(Self, Self) -> Bool +pub fn ExplorerMode::not_equal(Self, Self) -> Bool pub(all) enum FileChange { Modified(@pathx.Relative) @@ -115,6 +137,8 @@ pub(all) enum FileChange { Removed(@pathx.Relative) Renamed(old~ : @pathx.Relative, new~ : @pathx.Relative) } derive(Eq) +pub fn FileChange::equal(Self, Self) -> Bool +pub fn FileChange::not_equal(Self, Self) -> Bool pub(all) enum FileContent { Binary @@ -127,6 +151,8 @@ pub(all) struct FileEntry { path : @pathx.Relative is_dir : Bool } derive(Eq) +pub fn FileEntry::equal(Self, Self) -> Bool +pub fn FileEntry::not_equal(Self, Self) -> Bool pub enum FileIndex { IndexEmpty @@ -134,12 +160,53 @@ pub enum FileIndex { IndexFailed IndexReady(files~ : Array[@pathx.Relative], truncated~ : Bool, refreshing~ : Bool, refresh_again~ : Bool) } derive(Eq) +pub fn FileIndex::equal(Self, Self) -> Bool +pub fn FileIndex::not_equal(Self, Self) -> Bool pub(all) struct FileStat { path : @pathx.Relative sig : String? } +pub(all) enum GitCommitFiles { + GitCommitFilesIdle + GitCommitFilesLoading(commit~ : String) + GitCommitFilesFailed(commit~ : String, message~ : String) + GitCommitFilesReady(commit~ : String, parent~ : String?, files~ : Array[@protocol.GitHistoricalChange]) +} derive(Eq) + +pub(all) enum GitGraphState { + GitGraphIdle + GitGraphLoading + GitGraphFailed(String) + GitGraphReady(repository~ : Bool, snapshot~ : @protocol.GitHistorySnapshot, commits~ : Array[@protocol.GitHistoryCommit], has_more~ : Bool, loading_more~ : Bool, load_error~ : String?) +} derive(Eq) + +type GraphLane derive(Eq) + +type GraphRow derive(Eq) + +pub(all) enum HistoryBlob { + HistoryBlobLoading + HistoryBlobMissing + HistoryBlobBinary + HistoryBlobOversized + HistoryBlobContent(String) + HistoryBlobError(String) +} derive(Eq) + +pub(all) struct HistoryDiffDoc { + diff : @dock.GitHistoryDiff + generation : Int + original : HistoryBlob + modified : HistoryBlob +} derive(Eq) + +pub(all) enum HistoryDiffSide { + HistoryOriginal + HistoryModified +} derive(Eq) + pub struct ModelUri(String) pub fn ModelUri::of(@common.Uri) -> Self @@ -159,14 +226,31 @@ pub(all) enum Msg { ChangesLoaded(owner~ : @interop.ConversationKey, generation~ : Int, repository~ : Bool, head~ : String?, files~ : Array[ChangedFile]) ChangesFailed(owner~ : @interop.ConversationKey, generation~ : Int, message~ : String) ChangesPollDue + GitHeadObserved(owner~ : @interop.ConversationKey, head~ : String?) + GitHistoryRefreshRequested + GitHistoryLoadMoreRequested + GitHistoryLoaded(owner~ : @interop.ConversationKey, generation~ : Int, append~ : Bool, reply~ : @protocol.GitHistoryReply) + GitHistoryFailed(owner~ : @interop.ConversationKey, generation~ : Int, append~ : Bool, message~ : String) + GitCommitSelected(String) + GitCommitChangesLoaded(owner~ : @interop.ConversationKey, generation~ : Int, reply~ : @protocol.GitCommitChangesReply) + GitCommitChangesFailed(owner~ : @interop.ConversationKey, generation~ : Int, commit~ : String, message~ : String) + GitCommitChangesRetry(String) + HistoricalFileSelected(@dock.GitHistoryDiff) + ToggleChangesSection + ToggleGitGraphSection FileSelected(path~ : @pathx.Relative, name~ : String) ChangedFileSelected(ChangedFile) DiffLoadingDue(owner~ : @interop.ConversationKey, path~ : @pathx.Relative, generation~ : Int) ToggleReviewDiff ToggleReviewDiffLayout + ReviewDiffModeSelected(ReviewDiffMode) + ToggleReviewDiffIgnoreComments + ReviewDiffStatusChanged(ReviewDiffStatus) ToggleReviewProgress GitOriginalLoaded(owner~ : @interop.ConversationKey, path~ : @pathx.Relative, generation~ : Int, background~ : Bool, original~ : @protocol.GitOriginalFileReply) GitOriginalFailed(owner~ : @interop.ConversationKey, path~ : @pathx.Relative, generation~ : Int, background~ : Bool, message~ : String) + HistoryBlobLoaded(owner~ : @interop.ConversationKey, key~ : String, generation~ : Int, side~ : HistoryDiffSide, blob~ : @protocol.GitOriginalFileReply) + HistoryBlobFailed(owner~ : @interop.ConversationKey, key~ : String, generation~ : Int, side~ : HistoryDiffSide, message~ : String) OpenNavigationLocation(owner~ : @interop.ConversationKey, path~ : @pathx.Relative, range~ : @common.Range) FileLoaded(owner~ : @interop.ConversationKey, epoch~ : Int, path~ : @pathx.Relative, name~ : String, file~ : FileContent, file_link_generation~ : Int?, review_generation~ : Int?, range~ : @common.Range?, annotation~ : String?) FileFailed(owner~ : @interop.ConversationKey, epoch~ : Int, path~ : @pathx.Relative, file_link_generation~ : Int?, review_generation~ : Int?, message~ : String) @@ -178,7 +262,7 @@ pub(all) enum Msg { DiagnosticsPublished(channel~ : @interop.ChannelId, absolute~ : @common.Uri, diagnostics~ : Array[@protocol.LspDiagnostic]) LanguageRequestFailed(owner~ : @interop.ConversationKey, message~ : String) EditorCommentAdded(resource~ : ModelUri, file~ : @pathx.Relative, range~ : @common.Range, quote~ : String, comment~ : String, feedback_id~ : String) - UnifiedDiffCommentAdded(file~ : @pathx.Relative, original_line_number~ : Int?, modified_line_number~ : Int?, quote~ : String, comment~ : String, resource~ : ModelUri?, feedback_id~ : String?) + DiffCommentAdded(file~ : @pathx.Relative, original_line_number~ : Int?, modified_line_number~ : Int?, quote~ : String, comment~ : String, resource~ : ModelUri?, feedback_id~ : String?) } pub(all) enum ReconciledPlacement { @@ -186,8 +270,10 @@ pub(all) enum ReconciledPlacement { PlacementLive(@interop.CheckoutPlacement) PlacementParked(last_known~ : @interop.CheckoutPlacement?) } derive(Eq) +pub fn ReconciledPlacement::equal(Self, Self) -> Bool pub fn ReconciledPlacement::identity(Self) -> @interop.CheckoutPlacement? pub fn ReconciledPlacement::is_parked(Self) -> Bool +pub fn ReconciledPlacement::not_equal(Self, Self) -> Bool pub fn ReconciledPlacement::reconcile(Self, @interop.CheckoutPlacement?) -> Self pub(all) enum ReviewBaseline { @@ -198,11 +284,27 @@ pub(all) enum ReviewBaseline { ReviewOversized ReviewFailed(String) } derive(Eq) +pub fn ReviewBaseline::equal(Self, Self) -> Bool +pub fn ReviewBaseline::not_equal(Self, Self) -> Bool pub(all) enum ReviewDiffLayout { ReviewSideBySide - ReviewUnified + ReviewInline } derive(Eq) +pub fn ReviewDiffLayout::equal(Self, Self) -> Bool +pub fn ReviewDiffLayout::not_equal(Self, Self) -> Bool + +pub(all) enum ReviewDiffMode { + ReviewDiffCore + ReviewDiffToken + ReviewDiffTree +} derive(Eq, @debug.Debug) + +pub(all) struct ReviewDiffStatus { + requested : ReviewDiffMode + effective : ReviewDiffMode + fell_back : Bool +} derive(Eq, @debug.Debug) pub(all) struct ReviewState { generation : Int @@ -211,11 +313,15 @@ pub(all) struct ReviewState { view_mode : ReviewViewMode selected : ChangedFile } derive(Eq) +pub fn ReviewState::equal(Self, Self) -> Bool +pub fn ReviewState::not_equal(Self, Self) -> Bool pub(all) enum ReviewViewMode { ReviewSource - ReviewUnifiedDiff + ReviewDiff } derive(Eq) +pub fn ReviewViewMode::equal(Self, Self) -> Bool +pub fn ReviewViewMode::not_equal(Self, Self) -> Bool pub(all) struct State { owner : @interop.ConversationKey? @@ -225,7 +331,21 @@ pub(all) struct State { search : @grep_search.State review_view_mode : ReviewViewMode review_diff_layout : ReviewDiffLayout + review_diff_mode : ReviewDiffMode + review_diff_ignore_comments : Bool + review_diff_status : ReviewDiffStatus? changes : ChangeList + git_graph : GitGraphState + git_graph_generation : Int + git_graph_selected : String? + git_commit_files : GitCommitFiles + git_commit_files_generation : Int + git_actual_head : String? + git_actual_head_known : Bool + changes_collapsed : Bool + git_graph_collapsed : Bool + history_docs : Map[String, HistoryDiffDoc] + history_generation : Int reviewed_changes : Array[ChangedFile] changes_generation : Int request_epoch : Int @@ -249,6 +369,8 @@ pub(all) struct State { error : String? } derive(Eq) pub fn State::empty() -> Self +pub fn State::equal(Self, Self) -> Bool +pub fn State::not_equal(Self, Self) -> Bool pub fn State::prepare_owner(Self, @interop.ConversationKey, Array[@pathx.Relative]) -> Self pub fn State::retire_watcher(Self, @cmd.Emit[Msg], Ctx) -> (Self, @cmd.Cmd) @@ -261,11 +383,15 @@ pub(all) enum TabState { TabUnavailable(String) TabDeleted } derive(Eq) +pub fn TabState::equal(Self, Self) -> Bool +pub fn TabState::not_equal(Self, Self) -> Bool pub(all) enum TreeLayout { BottomPanel RightSidebar } derive(Eq) +pub fn TreeLayout::equal(Self, Self) -> Bool +pub fn TreeLayout::not_equal(Self, Self) -> Bool pub fn TreeLayout::parse(String) -> Self pub fn TreeLayout::wire(Self) -> String @@ -276,6 +402,8 @@ pub(all) struct WatchedPaths { directories : Array[@pathx.Relative] recursive : Bool } derive(Eq) +pub fn WatchedPaths::equal(Self, Self) -> Bool +pub fn WatchedPaths::not_equal(Self, Self) -> Bool // Type aliases diff --git a/desktop/frontend/fileeditor/review_progress_wbtest.mbt b/desktop/frontend/fileeditor/review_progress_wbtest.mbt index e250e2f42..fc8f38db2 100644 --- a/desktop/frontend/fileeditor/review_progress_wbtest.mbt +++ b/desktop/frontend/fileeditor/review_progress_wbtest.mbt @@ -9,7 +9,7 @@ fn review_progress_state() -> (State, Ctx, ChangedFile) raise { generation: 1, working_generation: 1, baseline: ReviewContent("old source"), - view_mode: ReviewUnifiedDiff, + view_mode: ReviewDiff, selected, }), } diff --git a/desktop/frontend/fileeditor/review_surfaces_wbtest.mbt b/desktop/frontend/fileeditor/review_surfaces_wbtest.mbt index 5d3ff853f..a549ccc24 100644 --- a/desktop/frontend/fileeditor/review_surfaces_wbtest.mbt +++ b/desktop/frontend/fileeditor/review_surfaces_wbtest.mbt @@ -38,7 +38,12 @@ test "desktop review keeps sibling hosts stable across source and diff modes" { ) assert_true( source.contains( - "id=\"unified-diff-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + "id=\"markdown-viewer-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + ), + ) + assert_true( + source.contains( + "id=\"diff-editor-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", ), ) let source_breadcrumb = render_review_html( @@ -47,7 +52,7 @@ test "desktop review keeps sibling hosts stable across source and diff modes" { assert_true(source_breadcrumb.contains("aria-label=\"Full diff\"")) assert_true(source_breadcrumb.contains("aria-pressed=\"false\"")) assert_true(source_breadcrumb.contains(">View full diff")) - assert_false(source_breadcrumb.contains("aria-label=\"Unified diff layout\"")) + assert_false(source_breadcrumb.contains("aria-label=\"Inline diff layout\"")) assert_true(source_breadcrumb.contains(">Mark reviewed")) assert_true( source_breadcrumb.contains("aria-label=\"Changed file navigation\""), @@ -56,10 +61,7 @@ test "desktop review keeps sibling hosts stable across source and diff modes" { let diff_doc = { ..source_doc, - review: source_doc.review.map(review => { - ..review, - view_mode: ReviewUnifiedDiff, - }), + review: source_doc.review.map(review => { ..review, view_mode: ReviewDiff }), } let diff_docs = source_docs.copy() diff_docs[path] = diff_doc @@ -71,7 +73,12 @@ test "desktop review keeps sibling hosts stable across source and diff modes" { ), ) assert_true( - diff.contains("id=\"unified-diff-host\" class=\"viewer-host editor-shell\""), + diff.contains( + "id=\"markdown-viewer-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + ), + ) + assert_true( + diff.contains("id=\"diff-editor-host\" class=\"viewer-host editor-shell\""), ) let diff_breadcrumb = render_review_html( breadcrumb_view(test_emit(), diff_state, ctx), @@ -79,22 +86,47 @@ test "desktop review keeps sibling hosts stable across source and diff modes" { assert_true(diff_breadcrumb.contains("aria-label=\"Full diff\"")) assert_true(diff_breadcrumb.contains("aria-pressed=\"true\"")) assert_true(diff_breadcrumb.contains(">View file")) - assert_true(diff_breadcrumb.contains("aria-label=\"Unified diff layout\"")) + assert_true(diff_breadcrumb.contains("aria-label=\"Inline diff layout\"")) assert_true(diff_breadcrumb.contains("aria-pressed=\"false\"")) - assert_true(diff_breadcrumb.contains("data-layout=\"split\"")) + assert_true(diff_breadcrumb.contains("data-layout=\"side-by-side\"")) assert_true(diff_breadcrumb.contains("review-layout-icon split")) + assert_true(diff_breadcrumb.contains("aria-label=\"Comparison algorithm\"")) + assert_true(diff_breadcrumb.contains("data-diff-mode=\"core\"")) + assert_true(diff_breadcrumb.contains("data-diff-mode=\"token\"")) + assert_true(diff_breadcrumb.contains("data-diff-mode=\"tree\"")) + assert_true(diff_breadcrumb.contains("aria-label=\"Ignore comments\"")) - let unified_breadcrumb = render_review_html( + let fallback_breadcrumb = render_review_html( breadcrumb_view( test_emit(), - { ..diff_state, review_diff_layout: ReviewUnified }, + { + ..diff_state, + review_diff_mode: ReviewDiffTree, + review_diff_status: Some({ + requested: ReviewDiffTree, + effective: ReviewDiffToken, + fell_back: true, + }), + }, ctx, ), ) - assert_true(unified_breadcrumb.contains("aria-label=\"Unified diff layout\"")) - assert_true(unified_breadcrumb.contains("aria-pressed=\"true\"")) - assert_true(unified_breadcrumb.contains("data-layout=\"unified\"")) - assert_true(unified_breadcrumb.contains("review-layout-icon unified")) + assert_true( + fallback_breadcrumb.contains("Tree unavailable — showing Token diff"), + ) + assert_true(fallback_breadcrumb.contains("aria-live=\"polite\"")) + + let inline_breadcrumb = render_review_html( + breadcrumb_view( + test_emit(), + { ..diff_state, review_diff_layout: ReviewInline }, + ctx, + ), + ) + assert_true(inline_breadcrumb.contains("aria-label=\"Inline diff layout\"")) + assert_true(inline_breadcrumb.contains("aria-pressed=\"true\"")) + assert_true(inline_breadcrumb.contains("data-layout=\"inline\"")) + assert_true(inline_breadcrumb.contains("review-layout-icon inline")) let reviewed_state = { ..diff_state, reviewed_changes: [selected] } let reviewed_breadcrumb = render_review_html( @@ -109,6 +141,86 @@ test "desktop review keeps sibling hosts stable across source and diff modes" { assert_true(reviewed_tree.contains("id=\"changed-file-row-0\"")) } +///| +test "desktop explicitly routes Markdown source while DiffEditor stays code-only" { + let path = @pathx.Relative("docs/Guide.MD") + let ctx = { ..test_ctx(), open_files: [path], active_file: Some(path) } + let docs = owned_state().docs.copy() + docs[path] = loaded_doc(path.0) + let source_state = { ..owned_state(), docs, } + let source = render_review_html(body_view(test_emit(), source_state, ctx)) + assert_true( + source.contains( + "id=\"viewer-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + ), + ) + assert_true( + source.contains( + "id=\"markdown-viewer-host\" class=\"viewer-host editor-shell\"", + ), + ) + assert_true( + source.contains( + "id=\"diff-editor-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + ), + ) + + let selected = test_modified_change(path) + let diff_docs = docs.copy() + diff_docs[path] = { + ..loaded_doc(path.0), + review: Some({ + generation: 1, + working_generation: 1, + baseline: ReviewContent("# Before"), + view_mode: ReviewDiff, + selected, + }), + } + let diff = render_review_html( + body_view(test_emit(), { ..source_state, docs: diff_docs }, ctx), + ) + assert_true( + diff.contains( + "id=\"viewer-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + ), + ) + assert_true( + diff.contains( + "id=\"markdown-viewer-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + ), + ) + assert_true( + diff.contains("id=\"diff-editor-host\" class=\"viewer-host editor-shell\""), + ) +} + +///| +test "non-MoonBit full diff omits specialized provider controls" { + let path = @pathx.Relative("README.md") + let ctx = { ..test_ctx(), open_files: [path], active_file: Some(path) } + let selected = test_modified_change(path) + let docs = owned_state().docs.copy() + docs[path] = { + ..loaded_doc(path.0), + review: Some({ + generation: 1, + working_generation: 1, + baseline: ReviewContent("old text"), + view_mode: ReviewDiff, + selected, + }), + } + let breadcrumb = render_review_html( + breadcrumb_view( + test_emit(), + { ..owned_state(), docs, review_diff_mode: ReviewDiffTree }, + ctx, + ), + ) + assert_false(breadcrumb.contains("aria-label=\"Comparison algorithm\"")) +} + ///| test "pending full diff never reveals the source surface" { let path = @pathx.Relative("src/main.mbt") @@ -120,7 +232,7 @@ test "pending full diff never reveals the source surface" { generation: 1, working_generation: 1, baseline: ReviewLoading, - view_mode: ReviewUnifiedDiff, + view_mode: ReviewDiff, selected, }), } @@ -135,7 +247,7 @@ test "pending full diff never reveals the source surface" { ) assert_true( pending.contains( - "id=\"unified-diff-host\" class=\"viewer-host editor-shell\"", + "id=\"diff-editor-host\" class=\"viewer-host editor-shell\"", ), ) assert_false(pending.contains("Loading diff for main.mbt…")) @@ -171,7 +283,7 @@ test "pending full diff never reveals the source surface" { ) assert_true( unavailable.contains( - "id=\"unified-diff-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", + "id=\"diff-editor-host\" class=\"viewer-host editor-shell moonbit-viewer-surface-hidden\"", ), ) } diff --git a/desktop/frontend/fileeditor/review_view_mode_wbtest.mbt b/desktop/frontend/fileeditor/review_view_mode_wbtest.mbt index 2684c812b..2eebef17c 100644 --- a/desktop/frontend/fileeditor/review_view_mode_wbtest.mbt +++ b/desktop/frontend/fileeditor/review_view_mode_wbtest.mbt @@ -3,7 +3,7 @@ test "new reviews default to the full diff" { let path = @pathx.Relative("src/main.mbt") let change = test_modified_change(path) let state = owned_state() - assert_true(state.review_view_mode is ReviewUnifiedDiff) + assert_true(state.review_view_mode is ReviewDiff) assert_true(state.review_diff_layout is ReviewSideBySide) let (_, opened) = open_changed_document( test_emit(), @@ -14,7 +14,7 @@ test "new reviews default to the full diff" { ) assert_true( opened.docs.get(path) - is Some({ review: Some({ view_mode: ReviewUnifiedDiff, .. }), .. }), + is Some({ review: Some({ view_mode: ReviewDiff, .. }), .. }), ) } @@ -29,19 +29,19 @@ test "full diff layout toggles independently and source mode preserves it" { generation: 1, working_generation: 1, baseline: ReviewContent("old source"), - view_mode: ReviewUnifiedDiff, + view_mode: ReviewDiff, selected, }), } let ctx = { ..test_ctx(), open_files: [path], active_file: Some(path) } - let (_, unified) = update( + let (_, inline) = update( test_emit(), ToggleReviewDiffLayout, { ..owned_state(), docs, }, ctx, ) - assert_true(unified.review_diff_layout is ReviewUnified) - let source_docs = unified.docs.copy() + assert_true(inline.review_diff_layout is ReviewInline) + let source_docs = inline.docs.copy() source_docs[path] = { ..source_docs.get(path).unwrap(), review: source_docs.get(path).unwrap().review.map(review => { @@ -52,10 +52,10 @@ test "full diff layout toggles independently and source mode preserves it" { let (_, unchanged) = update( test_emit(), ToggleReviewDiffLayout, - { ..unified, docs: source_docs }, + { ..inline, docs: source_docs }, ctx, ) - assert_true(unchanged.review_diff_layout is ReviewUnified) + assert_true(unchanged.review_diff_layout is ReviewInline) } ///| @@ -86,10 +86,10 @@ test "full diff remains selected when opening another changed file" { { ..owned_state(), docs, review_view_mode: ReviewSource }, first_ctx, ) - assert_true(full_diff.review_view_mode is ReviewUnifiedDiff) + assert_true(full_diff.review_view_mode is ReviewDiff) assert_true( full_diff.docs.get(first_path) - is Some({ review: Some({ view_mode: ReviewUnifiedDiff, .. }), .. }), + is Some({ review: Some({ view_mode: ReviewDiff, .. }), .. }), ) let second_ctx = { @@ -106,7 +106,7 @@ test "full diff remains selected when opening another changed file" { ) assert_true( switched.docs.get(second_path) - is Some({ review: Some({ view_mode: ReviewUnifiedDiff, .. }), .. }), + is Some({ review: Some({ view_mode: ReviewDiff, .. }), .. }), ) let loaded_docs = switched.docs.copy() @@ -116,7 +116,7 @@ test "full diff remains selected when opening another changed file" { generation: switched.review_generation, working_generation: switched.review_generation, baseline: ReviewContent("second old source"), - view_mode: ReviewUnifiedDiff, + view_mode: ReviewDiff, selected: second_change, }), } diff --git a/desktop/frontend/fileeditor/state.mbt b/desktop/frontend/fileeditor/state.mbt index e776cc6b0..7f507f8e0 100644 --- a/desktop/frontend/fileeditor/state.mbt +++ b/desktop/frontend/fileeditor/state.mbt @@ -75,12 +75,12 @@ pub(all) enum ReviewBaseline { } derive(Eq) ///| -/// The per-tab review presentation. Source mode keeps the existing Viewer and -/// quick-diff gutter; full-diff mode shows the context-bounded comparison in -/// the separately selected split or unified layout. +/// The per-tab review presentation. Source mode keeps the selected code or +/// Markdown surface; full-diff mode shows the context-bounded source comparison +/// in the separately selected side-by-side or inline layout. pub(all) enum ReviewViewMode { ReviewSource - ReviewUnifiedDiff + ReviewDiff } derive(Eq) ///| @@ -89,9 +89,30 @@ pub(all) enum ReviewViewMode { /// layout for the next full-diff open and for the next changed file. pub(all) enum ReviewDiffLayout { ReviewSideBySide - ReviewUnified + ReviewInline } derive(Eq) +///| +/// Desktop-owned comparison algorithm preference. Non-MoonBit comparisons +/// force Core at the provider boundary without overwriting this preference, +/// so returning to a `.mbt` review restores Token or Tree. +pub(all) enum ReviewDiffMode { + ReviewDiffCore + ReviewDiffToken + ReviewDiffTree +} derive(Eq, Debug) + +///| +/// The last provider result for the visible MoonBit comparison. A requested +/// Tree calculation can legitimately settle as Token; malformed specialized +/// output settles as Core. The host renders this fallback instead of placing +/// product controls inside the reusable diff editor. +pub(all) struct ReviewDiffStatus { + requested : ReviewDiffMode + effective : ReviewDiffMode + fell_back : Bool +} derive(Eq, Debug) + ///| /// One changed-file review request parked on its document. Separate request /// generations fence its HEAD reply and newest working-tree read; keeping the @@ -387,9 +408,18 @@ pub(all) struct State { // changed-file reviews inherit it so next/previous and tree navigation do // not force the reviewer to request the full diff again. review_view_mode : ReviewViewMode - // Split/unified is likewise a reviewer preference rather than document - // state. The imperative DiffViewer retains both models while applying it. + // Side-by-side/inline is likewise a reviewer preference rather than + // document state. The imperative DiffEditor retains both models while + // applying it. review_diff_layout : ReviewDiffLayout + // The requested MoonBit-aware algorithm and comment policy are desktop + // preferences. The provider is forced to Core for other file types while + // these values remain parked for the next `.mbt` comparison. + review_diff_mode : ReviewDiffMode + review_diff_ignore_comments : Bool + // Updated from DiffEditor's completed provider transaction. `None` means a + // new model/options calculation has not yet produced host-visible status. + review_diff_status : ReviewDiffStatus? changes : ChangeList // Git graph state is conversation/workspace scoped like Changes, but is // refreshed only at meaningful repository boundaries rather than on the @@ -498,8 +528,11 @@ pub fn State::empty() -> State { tree_open: true, explorer_mode: ExplorerFiles, search: @grep_search.State::empty(), - review_view_mode: ReviewUnifiedDiff, + review_view_mode: ReviewDiff, review_diff_layout: ReviewSideBySide, + review_diff_mode: ReviewDiffCore, + review_diff_ignore_comments: true, + review_diff_status: None, changes: ChangeListIdle, git_graph: GitGraphIdle, git_graph_generation: 0, @@ -563,6 +596,9 @@ pub fn State::prepare_owner( explorer_mode: self.explorer_mode, search: self.search.reset_checkout(), review_view_mode: self.review_view_mode, + review_diff_layout: self.review_diff_layout, + review_diff_mode: self.review_diff_mode, + review_diff_ignore_comments: self.review_diff_ignore_comments, changes_generation: self.changes_generation, request_epoch: self.request_epoch + 1, file_link_generation: self.file_link_generation, @@ -781,12 +817,23 @@ pub(all) enum Msg { path~ : @pathx.Relative, generation~ : Int ) - // Switch the active changed-file review between the ordinary source Viewer - // and its full comparison. Unavailable/loading reviews ignore the action. + // Switch the active changed-file review between its selected source + // presentation and the full comparison. Unavailable/loading reviews ignore + // the action. ToggleReviewDiff - // Switch a visible full comparison between split panes and unified rows. + // Switch a visible full comparison between side-by-side panes and inline + // rows. // Source mode and unavailable/loading comparisons ignore the action. ToggleReviewDiffLayout + // Select the desktop-owned comparison provider mode. Token and Tree are + // accepted only for a visible `.mbt` comparison; other files keep the + // preference parked while their provider is forced to Core. + ReviewDiffModeSelected(ReviewDiffMode) + ToggleReviewDiffIgnoreComments + // Provider completion is asynchronous with respect to the Rabbita update + // loop. This message republishes the requested/effective mode so the host + // toolbar can announce Tree→Token and specialized→Core fallbacks. + ReviewDiffStatusChanged(ReviewDiffStatus) // Mark the active changed-file review as reviewed, or return it to the // unreviewed queue. Ordinary file tabs ignore the action. ToggleReviewProgress @@ -940,15 +987,13 @@ pub(all) enum Msg { // without pretending it exists in the working document. // Like `EditorCommentAdded`, the main update intercepts this message and // turns it into a deduplicated annotation chip. - UnifiedDiffCommentAdded( + DiffCommentAdded( file~ : @pathx.Relative, original_line_number~ : Int?, modified_line_number~ : Int?, quote~ : String, comment~ : String, - // Present when the comment came from the new original-side Viewer. The - // legacy standalone renderer has no feedback bubble and leaves both keys - // absent. + // Present when the comment came from the original-side code kernel. resource~ : ModelUri?, feedback_id~ : String? ) diff --git a/desktop/frontend/fileeditor/update.mbt b/desktop/frontend/fileeditor/update.mbt index 47298aa03..e34cd9d29 100644 --- a/desktop/frontend/fileeditor/update.mbt +++ b/desktop/frontend/fileeditor/update.mbt @@ -47,10 +47,10 @@ fn Doc::quick_diff_original(self : Doc) -> String? { } ///| -/// The two textual sides available to the switchable DiffViewer. +/// The two textual sides available to the switchable DiffEditor. /// Added files use an empty HEAD side and deleted files use an empty working /// side. Loading, binary, oversized, and failed reads do not invent text. -fn Doc::unified_diff_content(self : Doc) -> (String, String)? { +fn Doc::diff_content(self : Doc) -> (String, String)? { guard self.review is Some(review) else { return None } let original = match review.baseline { ReviewContent(content) => content @@ -72,18 +72,16 @@ fn Doc::unified_diff_content(self : Doc) -> (String, String)? { ///| /// Diff inputs only enter the comparison while this tab's cached copy of the -/// panel preference is unified mode. Keeping the copy on its review state also +/// panel preference is full-diff mode. Keeping the copy on its review state also /// preserves it through generation-fenced source/baseline refreshes. -fn Doc::visible_unified_diff(self : Doc) -> (String, String)? { - guard self.review is Some({ view_mode: ReviewUnifiedDiff, .. }) else { - return None - } - self.unified_diff_content() +fn Doc::visible_diff(self : Doc) -> (String, String)? { + guard self.review is Some({ view_mode: ReviewDiff, .. }) else { return None } + self.diff_content() } ///| -fn Doc::shows_unified_diff(self : Doc) -> Bool { - self.visible_unified_diff() is Some(_) +fn Doc::shows_diff(self : Doc) -> Bool { + self.visible_diff() is Some(_) } ///| @@ -101,11 +99,11 @@ fn State::editor_search_highlights( ///| /// Whether a requested full diff is still waiting for one textual side. Keep -/// this distinct from `shows_unified_diff`: the comparison has no model yet, +/// this distinct from `shows_diff`: the comparison has no model yet, /// but revealing the source Viewer while either read settles produces a /// source-to-diff flash. -fn Doc::unified_diff_is_pending(self : Doc) -> Bool { - guard self.review is Some(review) && review.view_mode is ReviewUnifiedDiff else { +fn Doc::diff_is_pending(self : Doc) -> Bool { + guard self.review is Some(review) && review.view_mode is ReviewDiff else { return false } match (review.baseline, self.state) { @@ -119,8 +117,8 @@ fn Doc::unified_diff_is_pending(self : Doc) -> Bool { /// The surface selected for the current render. A full comparison owns the /// diff host from the moment it is requested, including its loading interval; /// a settled unavailable comparison falls back to the ordinary file surface. -fn Doc::uses_unified_diff_surface(self : Doc) -> Bool { - self.shows_unified_diff() || self.unified_diff_is_pending() +fn Doc::uses_diff_surface(self : Doc) -> Bool { + self.shows_diff() || self.diff_is_pending() } ///| @@ -133,8 +131,8 @@ fn ReviewState::diff_loading_generation(self : ReviewState) -> Int { } ///| -fn Doc::unified_diff_pending_generation(self : Doc) -> Int? { - guard self.unified_diff_is_pending() && self.review is Some(review) else { +fn Doc::diff_pending_generation(self : Doc) -> Int? { + guard self.diff_is_pending() && self.review is Some(review) else { return None } Some(review.diff_loading_generation()) @@ -142,9 +140,7 @@ fn Doc::unified_diff_pending_generation(self : Doc) -> Int? { ///| fn State::shows_delayed_diff_loading(self : State, doc : Doc) -> Bool { - guard doc.unified_diff_pending_generation() is Some(generation) else { - return false - } + guard doc.diff_pending_generation() is Some(generation) else { return false } self.diff_loading_generation == Some(generation) } @@ -158,7 +154,7 @@ fn delayed_diff_loading( path : @pathx.Relative, doc : Doc, ) -> @cmd.Cmd { - guard doc.unified_diff_pending_generation() is Some(generation) else { + guard doc.diff_pending_generation() is Some(generation) else { return @cmd.none } @cmd.delay( @@ -228,7 +224,7 @@ fn show_tab_impl( relative=path, restore_scroll=true, review_original?=doc.quick_diff_original(), - unified_diff?=doc.visible_unified_diff(), + diff?=doc.visible_diff(), search_highlights?, ) if sync_diagnostics && @@ -262,7 +258,7 @@ fn show_tab_impl( content, relative=path, restore_scroll=true, - unified_diff?=doc.visible_unified_diff(), + diff?=doc.visible_diff(), search_highlights?, ) _ => clear_document() @@ -525,7 +521,7 @@ pub fn open_changed_document( let path = change.path // Selecting a Changes row is activation, not a manual reload. Reuse the // exact cached review (including one whose paired reads are still pending) - // so repeated clicks cannot replace its generation, clear its DiffViewer + // so repeated clicks cannot replace its generation, clear its DiffEditor // models, and briefly paint an empty comparison. Exact row identity matters: // Git can legitimately expose two different reviews for the same path. if state.docs.get(path) is Some(doc) && @@ -1895,6 +1891,8 @@ pub fn sync( search: state.search.reset_checkout(), review_view_mode: state.review_view_mode, review_diff_layout: state.review_diff_layout, + review_diff_mode: state.review_diff_mode, + review_diff_ignore_comments: state.review_diff_ignore_comments, changes_collapsed: state.changes_collapsed, git_graph_collapsed: state.git_graph_collapsed, // Request tokens must outlive the per-conversation cache. Resetting @@ -2193,7 +2191,7 @@ pub fn update( if state.owner == Some(owner) && ctx.active_file == Some(path) && state.docs.get(path) is Some(doc) && - doc.unified_diff_pending_generation() == Some(generation) { + doc.diff_pending_generation() == Some(generation) { (@cmd.none, { ..state, diff_loading_generation: Some(generation) }) } else { (@cmd.none, state) @@ -2202,12 +2200,12 @@ pub fn update( guard ctx.active_file is Some(path) && state.docs.get(path) is Some(doc) && doc.review is Some(_) && - doc.unified_diff_content() is Some(_) else { + doc.diff_content() is Some(_) else { return (@cmd.none, state) } let view_mode = match state.review_view_mode { - ReviewSource => ReviewUnifiedDiff - ReviewUnifiedDiff => ReviewSource + ReviewSource => ReviewDiff + ReviewDiff => ReviewSource } let docs = state.docs.copy() // Keep every parked review aligned with the panel preference. Otherwise @@ -2236,29 +2234,58 @@ pub fn update( // Viewer's 5px minimum while the diff is visible. Measure again only // after source mode has restored the host's real box. ReviewSource => @cmd.batch([show, request_viewer_remeasure()]) - ReviewUnifiedDiff => show + ReviewDiff => show } (command, { ..state, docs, review_view_mode: view_mode }) } ToggleReviewDiffLayout => { let review_ready = ctx.active_file is Some(path) && state.docs.get(path) is Some(doc) && - doc.review is Some({ view_mode: ReviewUnifiedDiff, .. }) && - doc.unified_diff_content() is Some(_) + doc.review is Some({ view_mode: ReviewDiff, .. }) && + doc.diff_content() is Some(_) let history_ready = ctx.active_history_diff is Some(diff) && state.history_docs.get(history_diff_key(diff)) is Some(history) && history.original.text() is Some(_) && history.modified.text() is Some(_) guard review_ready || history_ready else { return (@cmd.none, state) } let review_diff_layout = match state.review_diff_layout { - ReviewSideBySide => ReviewUnified - ReviewUnified => ReviewSideBySide + ReviewSideBySide => ReviewInline + ReviewInline => ReviewSideBySide } ( apply_review_diff_layout(review_diff_layout), { ..state, review_diff_layout, }, ) } + ReviewDiffModeSelected(mode) => { + guard moon_diff_ready(state, ctx) else { return (@cmd.none, state) } + ( + apply_review_diff_provider(mode, state.review_diff_ignore_comments), + { ..state, review_diff_mode: mode, review_diff_status: None }, + ) + } + ToggleReviewDiffIgnoreComments => { + guard moon_diff_ready(state, ctx) && + state.review_diff_mode != ReviewDiffCore else { + return (@cmd.none, state) + } + let ignore_comments = !state.review_diff_ignore_comments + ( + apply_review_diff_provider(state.review_diff_mode, ignore_comments), + { + ..state, + review_diff_ignore_comments: ignore_comments, + review_diff_status: None, + }, + ) + } + ReviewDiffStatusChanged(status) => + if moon_diff_ready(state, ctx) && + status.requested == state.review_diff_mode { + (@cmd.none, { ..state, review_diff_status: Some(status) }) + } else { + (@cmd.none, state) + } ToggleReviewProgress => { guard ctx.active_file is Some(path) && state.docs.get(path) is Some({ review: Some(review), .. }) && @@ -3056,7 +3083,7 @@ pub fn update( // position; only a genuinely fresh document opens at the top. restore_scroll=previous is TabLoaded(..), review_original?=doc.quick_diff_original(), - unified_diff?=next_doc.visible_unified_diff(), + diff?=next_doc.visible_diff(), search_highlights?=state.editor_search_highlights(path), ) if language_id(name) == "moonbit" && ctx.root() is Some(root) { @@ -3550,7 +3577,7 @@ pub fn update( // chip there, which then clears the editor bubble through // `remove_editor_feedback`. Reaching this arm means the main update // didn't intercept it; changing nothing is the safe answer. - EditorCommentAdded(..) | UnifiedDiffCommentAdded(..) => (@cmd.none, state) + EditorCommentAdded(..) | DiffCommentAdded(..) => (@cmd.none, state) } } @@ -4163,7 +4190,7 @@ test "reselecting a settled review keeps its cached comparison generation" { generation: 7, working_generation: 7, baseline: ReviewContent("HEAD source"), - view_mode: ReviewUnifiedDiff, + view_mode: ReviewDiff, selected: change, }), } @@ -4419,13 +4446,8 @@ test "deleted changes preview HEAD and working-file loads retain early baselines guard preview.docs.get(deleted_path) is Some(deleted_preview) else { fail("deleted review was missing") } - assert_eq( - deleted_preview.unified_diff_content(), - Some(("removed source", "")), - ) - assert_true( - deleted_preview.review is Some({ view_mode: ReviewUnifiedDiff, .. }), - ) + assert_eq(deleted_preview.diff_content(), Some(("removed source", ""))) + assert_true(deleted_preview.review is Some({ view_mode: ReviewDiff, .. })) let (_, source) = update(emit, ToggleReviewDiff, preview, ctx) assert_true( source.docs.get(deleted_path) @@ -4434,7 +4456,7 @@ test "deleted changes preview HEAD and working-file loads retain early baselines let (_, full_diff_again) = update(emit, ToggleReviewDiff, source, ctx) assert_true( full_diff_again.docs.get(deleted_path) - is Some({ review: Some({ view_mode: ReviewUnifiedDiff, .. }), .. }), + is Some({ review: Some({ view_mode: ReviewDiff, .. }), .. }), ) // The HEAD request may beat the ordinary filesystem read. Its result stays @@ -4503,7 +4525,7 @@ test "deleted changes preview HEAD and working-file loads retain early baselines guard loaded.docs.get(live_path) is Some(added_preview) else { fail("added review was missing") } - assert_eq(added_preview.unified_diff_content(), Some(("", "new"))) + assert_eq(added_preview.diff_content(), Some(("", "new"))) } ///| diff --git a/desktop/frontend/fileeditor/view.mbt b/desktop/frontend/fileeditor/view.mbt index 168f75d3b..fb3393b32 100644 --- a/desktop/frontend/fileeditor/view.mbt +++ b/desktop/frontend/fileeditor/view.mbt @@ -297,7 +297,8 @@ pub fn resize_handle_view() -> @html.Html { /// viewer with its notice overlay, and the workspace tree (expandable and /// always rooted at the workspace root) as a toggleable pane on /// the viewer's right. Always rendered so the viewer's host element -/// (`viewer-host` and `unified-diff-host`) stay put across renders and screen +/// (`viewer-host`, `markdown-viewer-host`, and `diff-editor-host`) stay put +/// across renders and screen /// switches; CSS hides the inactive surface and the tree pane when toggled off, /// and collapses the whole panel to zero width when it is closed. The children's /// order is structural — rabbita diffs by index, so every part renders @@ -307,31 +308,40 @@ pub fn body_view( state : State, ctx : Ctx, ) -> @html.Html { - // The stable elements the source Viewer and switchable DiffViewer attach - // their imperative DOM into; they must stay empty and never be re-keyed + // The stable elements the code Viewer, MarkdownViewer, and switchable + // DiffEditor attach their imperative DOM into; they stay empty and are + // never re-keyed // (see editor_panel.mbt). // `editor-shell` brings in the viewer's `--vscode-*` / `--editor-*` theme // variables (defined on that class in the bundled viewer.css); `data-theme` // picks the palette matching the app's own light/dark scheme (the shell's // default is dark, `light` overrides). The viewer's colors cascade from // this host. - let use_unified_diff_surface = if ctx.active_history_diff is Some(_) { + let use_diff_surface = if ctx.active_history_diff is Some(_) { true } else { match ctx.active_file { Some(path) => state.docs .get(path) - .map(doc => doc.uses_unified_diff_surface()) + .map(doc => doc.uses_diff_surface()) .unwrap_or(false) None => false } } + let use_markdown_surface = !use_diff_surface && + (match ctx.active_file { + Some(path) => + uses_markdown_presentation( + state.docs.get(path).map(doc => doc.name).unwrap_or(path.0), + ) + None => false + }) let viewer_host = @html.div( attrs=@html.Attrs::build() .id(ViewerHostId) .class( - if use_unified_diff_surface { + if use_diff_surface || use_markdown_surface { "viewer-host editor-shell moonbit-viewer-surface-hidden" } else { "viewer-host editor-shell" @@ -340,11 +350,24 @@ pub fn body_view( .data_set("theme", if ctx.dark_mode { "dark" } else { "light" }), ([] : Array[@html.Html]), ) - let unified_diff_host = @html.div( + let markdown_viewer_host = @html.div( attrs=@html.Attrs::build() - .id(DiffViewerHostId) + .id(MarkdownViewerHostId) .class( - if use_unified_diff_surface { + if use_markdown_surface { + "viewer-host editor-shell" + } else { + "viewer-host editor-shell moonbit-viewer-surface-hidden" + }, + ) + .data_set("theme", if ctx.dark_mode { "dark" } else { "light" }), + ([] : Array[@html.Html]), + ) + let diff_editor_host = @html.div( + attrs=@html.Attrs::build() + .id(DiffEditorHostId) + .class( + if use_diff_surface { "viewer-host editor-shell" } else { "viewer-host editor-shell moonbit-viewer-surface-hidden" @@ -356,7 +379,8 @@ pub fn body_view( @html.div(class="editor-body", [ @html.div(class="viewer-stack", [ viewer_host, - unified_diff_host, + markdown_viewer_host, + diff_editor_host, viewer_notice(state, ctx), ]), tree_pane(dispatch, state, ctx), @@ -382,10 +406,7 @@ fn viewer_notice(state : State, ctx : Ctx) -> @html.Html { } let diff_pending = match ctx.active_file { Some(path) => - state.docs - .get(path) - .map(doc => doc.unified_diff_is_pending()) - .unwrap_or(false) + state.docs.get(path).map(doc => doc.diff_is_pending()).unwrap_or(false) None => false } let text = match ctx.active_file { @@ -1678,6 +1699,7 @@ pub fn breadcrumb_view( @html.span(class="crumb-sep", "›"), @html.span(class="crumb crumb-file", diff.path.0), ]), + review_diff_mode_toolbar(dispatch, state, ctx), review_diff_layout_toggle(dispatch, state, ctx), @html.button( class="panel-toggle", @@ -1721,6 +1743,7 @@ pub fn breadcrumb_view( @html.div(class="crumb-path", crumbs), review_navigation(dispatch, state, ctx), review_badge(dispatch, state, ctx), + review_diff_mode_toolbar(dispatch, state, ctx), review_diff_layout_toggle(dispatch, state, ctx), review_progress(dispatch, state, ctx), @html.button( @@ -1743,6 +1766,132 @@ pub fn breadcrumb_view( ) } +///| +fn moon_diff_ready(state : State, ctx : Ctx) -> Bool { + let review_ready = ctx.active_file is Some(path) && + path.0.to_lower().has_suffix(".mbt") && + state.docs.get(path) is Some(doc) && + doc.review is Some({ view_mode: ReviewDiff, .. }) && + doc.diff_content() is Some(_) + let history_ready = ctx.active_history_diff is Some(diff) && + diff.path.0.to_lower().has_suffix(".mbt") && + state.history_docs.get(history_diff_key(diff)) is Some(history) && + history.original.text() is Some(_) && + history.modified.text() is Some(_) + review_ready || history_ready +} + +///| +fn ReviewDiffMode::label(self : ReviewDiffMode) -> String { + match self { + ReviewDiffCore => "Core" + ReviewDiffToken => "Token" + ReviewDiffTree => "Tree" + } +} + +///| +fn ReviewDiffMode::attribute(self : ReviewDiffMode) -> String { + match self { + ReviewDiffCore => "core" + ReviewDiffToken => "token" + ReviewDiffTree => "tree" + } +} + +///| +fn review_diff_mode_button( + dispatch : @cmd.Emit[Msg], + selected : ReviewDiffMode, + mode : ReviewDiffMode, +) -> @html.Html { + let active = selected == mode + @html.button( + type_="button", + class=if active { + "review-badge review-badge-action active" + } else { + "review-badge review-badge-action" + }, + title="Use " + mode.label() + " diff", + on_click=dispatch(ReviewDiffModeSelected(mode)), + attrs=@html.Attrs::build() + .data_set("diff-mode", mode.attribute()) + .aria_label(mode.label() + " diff") + .aria_pressed(active.to_string()), + mode.label(), + ) +} + +///| +fn review_diff_fallback_text(state : State) -> String { + guard state.review_diff_status is Some(status) && + status.requested == state.review_diff_mode && + status.fell_back else { + return "" + } + status.requested.label() + + " unavailable — showing " + + status.effective.label() + + " diff" +} + +///| +/// MoonBit-aware comparison policy belongs to the desktop review chrome. +/// Other file types omit these controls and the imperative provider forces +/// Core without overwriting the retained Token/Tree preference. +fn review_diff_mode_toolbar( + dispatch : @cmd.Emit[Msg], + state : State, + ctx : Ctx, +) -> @html.Html { + guard moon_diff_ready(state, ctx) else { + return @html.span(class="review-diff-mode-toolbar hidden", "") + } + let fallback = review_diff_fallback_text(state) + @html.div( + class="review-diff-mode-toolbar", + attrs=@html.Attrs::build() + .role("toolbar") + .aria_label("Comparison algorithm"), + [ + review_diff_mode_button(dispatch, state.review_diff_mode, ReviewDiffCore), + review_diff_mode_button(dispatch, state.review_diff_mode, ReviewDiffToken), + review_diff_mode_button(dispatch, state.review_diff_mode, ReviewDiffTree), + @html.button( + type_="button", + class=if state.review_diff_ignore_comments { + "review-badge review-badge-action active" + } else { + "review-badge review-badge-action" + }, + title=if state.review_diff_mode == ReviewDiffCore { + "Available in Token and Tree diff" + } else if state.review_diff_ignore_comments { + "Include comments in the comparison" + } else { + "Ignore comment-only changes" + }, + disabled=state.review_diff_mode == ReviewDiffCore, + on_click=dispatch(ToggleReviewDiffIgnoreComments), + attrs=@html.Attrs::build() + .aria_label("Ignore comments") + .aria_pressed(state.review_diff_ignore_comments.to_string()), + "Ignore comments", + ), + @html.span( + class=if fallback.is_empty() { + "review-diff-mode-status hidden" + } else { + "review-diff-mode-status" + }, + attrs=@html.Attrs::build().role("status").aria_live("polite"), + fallback, + ), + ], + ) +} + ///| /// The full comparison keeps its two models mounted while this compact control /// changes only their layout. Source mode hides the control but preserves the @@ -1754,8 +1903,8 @@ fn review_diff_layout_toggle( ) -> @html.Html { let review_ready = ctx.active_file is Some(path) && state.docs.get(path) is Some(doc) && - doc.review is Some({ view_mode: ReviewUnifiedDiff, .. }) && - doc.unified_diff_content() is Some(_) + doc.review is Some({ view_mode: ReviewDiff, .. }) && + doc.diff_content() is Some(_) let history_ready = ctx.active_history_diff is Some(diff) && state.history_docs.get(history_diff_key(diff)) is Some(history) && history.original.text() is Some(_) && @@ -1763,23 +1912,23 @@ fn review_diff_layout_toggle( guard review_ready || history_ready else { return @html.span(class="review-badge hidden", "") } - let unified = state.review_diff_layout == ReviewUnified + let inline = state.review_diff_layout == ReviewInline @html.button( type_="button", class="review-badge review-badge-action review-layout-toggle", - title=if unified { + title=if inline { "Use side-by-side diff layout" } else { - "Use unified diff layout" + "Use inline diff layout" }, on_click=dispatch(ToggleReviewDiffLayout), attrs=@html.Attrs::build() - .data_set("layout", if unified { "unified" } else { "split" }) - .aria_label("Unified diff layout") - .aria_pressed(unified.to_string()), + .data_set("layout", if inline { "inline" } else { "side-by-side" }) + .aria_label("Inline diff layout") + .aria_pressed(inline.to_string()), @html.span( - class=if unified { - "review-layout-icon unified" + class=if inline { + "review-layout-icon inline" } else { "review-layout-icon split" }, @@ -2053,9 +2202,9 @@ fn review_badge( let (text, title, action, pressed) = match ctx.active_file { Some(path) => match state.docs.get(path) { - Some(doc) if doc.unified_diff_content() is Some(_) => + Some(doc) if doc.diff_content() is Some(_) => match doc.review { - Some({ view_mode: ReviewUnifiedDiff, .. }) => + Some({ view_mode: ReviewDiff, .. }) => ( "View file", "Return to the source preview and quick-diff gutter", true, true, diff --git a/desktop/frontend/update.mbt b/desktop/frontend/update.mbt index 315f9a99d..cca1d0f5f 100644 --- a/desktop/frontend/update.mbt +++ b/desktop/frontend/update.mbt @@ -2161,7 +2161,7 @@ fn update_msg( // modified-side comment receives a concrete editor range, so reopening a // deleted-line chip cannot claim stale source columns. FileEditor( - UnifiedDiffCommentAdded( + DiffCommentAdded( file~, original_line_number~, modified_line_number~, @@ -14885,11 +14885,11 @@ test "EditorCommentAdded attaches a deduplicated annotation chip" { } ///| -test "UnifiedDiffCommentAdded preserves modified and original-only line identity" { +test "DiffCommentAdded preserves modified and original-only line identity" { let dispatch = test_dispatch() let file = @pathx.Relative("src/main.mbt") let added : Msg = FileEditor( - UnifiedDiffCommentAdded( + DiffCommentAdded( file~, original_line_number=None, modified_line_number=Some(7), @@ -14915,7 +14915,7 @@ test "UnifiedDiffCommentAdded preserves modified and original-only line identity ) assert_eq(added_mentions[0].range, Some(@common.Range::Range(7, 1, 7, 16))) let deleted : Msg = FileEditor( - UnifiedDiffCommentAdded( + DiffCommentAdded( file~, original_line_number=Some(3), modified_line_number=None, @@ -15023,7 +15023,7 @@ test "chip jumps relativize legacy absolute paths against the roots" { }), tree_open: false, explorer_mode: @fileeditor.ExplorerChanges, - review_view_mode: @fileeditor.ReviewUnifiedDiff, + review_view_mode: @fileeditor.ReviewDiff, changes_generation: 7, watch_generation: 11, }, @@ -15040,7 +15040,7 @@ test "chip jumps relativize legacy absolute paths against the roots" { assert_true(@dock.active_file(next.dock) is Some(Relative("src/main.mbt"))) assert_false(next.file_panel.tree_open) assert_true(next.file_panel.explorer_mode is @fileeditor.ExplorerChanges) - assert_true(next.file_panel.review_view_mode is @fileeditor.ReviewUnifiedDiff) + assert_true(next.file_panel.review_view_mode is @fileeditor.ReviewDiff) // The eager re-root retains 7. The jump keeps the explorer hidden while the // file is active, so Changes waits until the pane is shown before refreshing. assert_true(next.file_panel.changes_generation == 7) diff --git a/desktop/package/internal/packaging/packaging.mbt b/desktop/package/internal/packaging/packaging.mbt index cd19d58e4..28f651bb8 100644 --- a/desktop/package/internal/packaging/packaging.mbt +++ b/desktop/package/internal/packaging/packaging.mbt @@ -65,9 +65,9 @@ fn viewer_css_sources() -> Array[String] { "viewer/browser/diff_editor/diff_editor.css", "viewer/contrib/folding/browser/folding.css", "viewer/contrib/contextmenu/browser/contextmenu.css", "viewer/contrib/definition/browser/definition.css", "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", "internal/viewer/browser/markdown_document/markdown_document.css", + "viewer/contrib/quick_diff/browser/quick_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", + "internal/viewer/browser/markdown_document/markdown_document.css", ] } diff --git a/desktop/styles/file_panel.css b/desktop/styles/file_panel.css index 6c61f417f..2eaf560a5 100644 --- a/desktop/styles/file_panel.css +++ b/desktop/styles/file_panel.css @@ -1903,6 +1903,41 @@ button.git-history-file:focus-visible { background: color-mix(in srgb, var(--color-accent) 18%, transparent); } +.review-diff-mode-toolbar { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; +} + +.review-diff-mode-toolbar.hidden, +.review-diff-mode-status.hidden { + display: none; +} + +.review-diff-mode-toolbar .review-badge { + max-width: none; + padding-inline: 5px; +} + +.review-diff-mode-toolbar .review-badge.active { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 20%, transparent); +} + +.review-diff-mode-toolbar .review-badge:disabled { + cursor: default; + opacity: 0.5; +} + +.review-diff-mode-status { + max-width: 220px; + overflow: hidden; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + text-overflow: ellipsis; +} + .review-layout-toggle { display: grid; place-items: center; @@ -1930,7 +1965,7 @@ button.git-history-file:focus-visible { border-left: 1px solid currentColor; } -.review-layout-icon.unified::after { +.review-layout-icon.inline::after { content: ""; position: absolute; top: 2px; diff --git a/editor/README.md b/editor/README.md index 929e2dcda..118a61e44 100644 --- a/editor/README.md +++ b/editor/README.md @@ -25,7 +25,7 @@ openseek: OpenSeek desktop file editor — primary product host repo: this repository { viewer: viewer — reusable workspace module { - facade: Viewer + DiffViewer facades + facade: Viewer + MarkdownViewer + DiffEditor facades common: viewer/common — DOM-free browser: browser runtime + contributions } diff --git a/editor/base/browser/pkg.generated.mbti b/editor/base/browser/pkg.generated.mbti index 6298a6eb9..ed26d49ca 100644 --- a/editor/base/browser/pkg.generated.mbti +++ b/editor/base/browser/pkg.generated.mbti @@ -70,6 +70,18 @@ pub fn GlobalPointerMoveMonitor::dispose(Self) -> Unit pub fn GlobalPointerMoveMonitor::start_monitoring(Self, @dom.Element, Int, Int, (@dom.MouseEvent) -> Unit, (@dom.Event?) -> Unit) -> Unit pub fn GlobalPointerMoveMonitor::stop_monitoring(Self, Bool, browser_event? : @dom.Event) -> Unit +pub struct RunOnceScheduler { + delay_ms : Int + mut handle : @common.Disposable? + schedule_timeout : (() -> Unit, Int) -> @common.Disposable +} +pub fn RunOnceScheduler::RunOnceScheduler(Int) -> Self +pub fn RunOnceScheduler::cancel(Self) -> Unit +pub fn RunOnceScheduler::dispose(Self) -> Unit +pub fn RunOnceScheduler::is_scheduled(Self) -> Bool +pub fn RunOnceScheduler::new_with_timer(Int, (() -> Unit, Int) -> @common.Disposable) -> Self +pub fn RunOnceScheduler::schedule(Self, () -> Unit) -> Unit + pub struct StandardMouseEvent { browser_event : @dom.MouseEvent left_button : Bool diff --git a/editor/base/browser/run_once_scheduler.mbt b/editor/base/browser/run_once_scheduler.mbt new file mode 100644 index 000000000..0c12e1535 --- /dev/null +++ b/editor/base/browser/run_once_scheduler.mbt @@ -0,0 +1,61 @@ +///| +/// A cancelable one-shot scheduler shared by browser widgets. Scheduling a +/// second callback replaces the first delay window; disposal permanently +/// cancels the currently pending callback. +pub struct RunOnceScheduler { + delay_ms : Int + mut handle : @base_common.Disposable? + schedule_timeout : (() -> Unit, Int) -> @base_common.Disposable +} + +///| +pub fn RunOnceScheduler::RunOnceScheduler(delay_ms : Int) -> RunOnceScheduler { + RunOnceScheduler::new_with_timer(delay_ms, schedule_timeout) +} + +///| +/// Capability-injected constructor used by deterministic widget tests. +pub fn RunOnceScheduler::new_with_timer( + delay_ms : Int, + schedule_timeout : (() -> Unit, Int) -> @base_common.Disposable, +) -> RunOnceScheduler { + { delay_ms, handle: None, schedule_timeout } +} + +///| +pub fn RunOnceScheduler::is_scheduled(self : RunOnceScheduler) -> Bool { + self.handle is Some(_) +} + +///| +pub fn RunOnceScheduler::schedule( + self : RunOnceScheduler, + callback : () -> Unit, +) -> Unit { + self.cancel() + self.handle = Some( + (self.schedule_timeout)( + () => { + self.handle = None + callback() + }, + self.delay_ms, + ), + ) +} + +///| +pub fn RunOnceScheduler::cancel(self : RunOnceScheduler) -> Unit { + match self.handle { + Some(handle) => { + handle.dispose() + self.handle = None + } + None => () + } +} + +///| +pub fn RunOnceScheduler::dispose(self : RunOnceScheduler) -> Unit { + self.cancel() +} diff --git a/editor/docs/architecture.md b/editor/docs/architecture.md index dd27767b2..92642342f 100644 --- a/editor/docs/architecture.md +++ b/editor/docs/architecture.md @@ -24,61 +24,66 @@ imports them and public names remain MoonBit-owned. ```mermaid flowchart TB - A[host document] --> B[TextModel
common/model] - B --> C[ViewModel
common/view_model] - C --> D[ViewLayout
common/view_layout] - D --> E[BrowserPresentation
closed family] - E --> F[Code: View
browser/view] - E --> G[Markdown: document view
+ hover and definition bridges] - F --> H[DOM] - G --> H + H[desktop or embedded host] --> P{explicit presentation policy} + P --> V[public Viewer] + P --> M[public MarkdownViewer] + P --> D[public DiffEditor] + V --> C[internal CodeEditorWidget] + M --> MW[internal MarkdownViewerWidget] + D --> DW[internal DiffEditorWidget] + DW --> VM[one DiffEditorViewModel] + DW --> E[DiffEditorEditors] + E --> O[original CodeEditorWidget] + E --> N[modified CodeEditorWidget] + C --> DOM[DOM] + MW --> DOM + O --> DOM + N --> DOM ``` -The stages after the host document live in `viewer/common/model`, -`viewer/common/view_model`, and `viewer/common/view_layout`. The Viewer-owned -closed `BrowserPresentation` family then selects -`internal/viewer/browser/view.View` for Code or -`internal/viewer/browser/markdown_document.MarkdownDocumentView` plus the -presentation-local hover and definition bridges for Markdown. - -`Viewer` keeps cross-domain ordering at the root and delegates private state to -five concrete owners: `EditorConfigurationState`, `ViewerModelSlot`, -`ViewerMount`, `EditorContributions`, and `CursorEventDelivery`. -`ViewerModelSlot.current` is the one nullable `ModelData`; its optional closed -`BrowserPresentation` makes active-root dispatch explicit. It contains exactly -`Code(CodeBrowserData)` and `Markdown(MarkdownBrowserData)`; root lookup, focus, -theme, layout, scroll, reveal, and lifecycle dispatch exhaustively over that -family instead of assuming a hidden Code `View`. The Code payload pairs a real -`View` with its retained mouse handler and view-scoped render/reveal facts. The -Markdown payload pairs a contribution-free document view with -presentation-local hover and definition bridges over the original model. -Public construction seeds -`EditorConfigurationState` synchronously from the host client box before model -attachment. Each `ModelData` then carries one generation-scoped initialization -boundary: the host invokes `Viewer::handle_initialized` after model, view-state, -and option setup, publishing stable visible-token demand outside `attach_model` -and independently of the animation-frame render loop. Repeated calls -re-stabilize the current model's demand instead of consuming one-shot state. - -Presentation selection belongs to the root Viewer. An exact lowercase URI-path -`.md` suffix, including `.mbt.md`, or the exact `markdown` language id selects -Markdown; every other model selects Code. URI matching excludes query and -fragment text and remains case-sensitive. Shells and embedders always supply an -ordinary caller-owned `TextModel`; they do not parse Markdown or select a -presentation. Both variants retain that original model and `ViewModel` as -source truth. - -Headless means `ViewerMount::Headless`: there is no caller host, placeholder, -browser presentation, DOM focus state, or root animation frame. A headless -Viewer may still have a model and `ViewModel`; `ModelData.presentation_kind` -retains the same automatic selection while `ModelData.browser=None`. On -supported construction paths, a mounted model always has -`Some(BrowserPresentation)`. `ViewerMount` installs and removes the active -presentation root without knowing its concrete payload. This is distinct from -a mounted Viewer with no model, which owns and paints one complete placeholder -pair. Mounting is one-way; disposal removes Viewer-owned DOM and clears mounted -placeholder/frame/focus state, but retains the caller's host for container -lookup. +The public root is a façade, not the editor implementation. `Viewer` is an +opaque wrapper around exactly one code-only `CodeEditorWidget` in +`internal/viewer/code_editor_widget`. That kernel owns configuration, one model +slot, `ViewModel`, `View`, input, render scheduling, decorations, ViewZones, +scroll/focus state, and code contributions. It neither imports the public root +package nor knows about Markdown or diff presentation policy. + +`MarkdownViewer` wraps `internal/viewer/markdown_viewer.MarkdownViewerWidget`. +It directly owns `MarkdownDocumentView` and its Markdown-specific hover, +definition, folding, projection, and native scroll lifetimes. The host supplies +`OrdinaryMarkdown` or `MoonBitMarkdown` explicitly; the widget never infers a +presentation from URI or language id. + +`DiffEditor` wraps `internal/viewer/diff_editor.DiffEditorWidget`. The internal +widget owns exactly two `CodeEditorWidget` kernels through `DiffEditorEditors` +and exactly one shared `DiffEditorViewModel`. It never constructs, contains, or +calls the public `Viewer`. Diff panes therefore show source code for every +resource, including Markdown, without a force-code escape hatch. + +Every kernel construction supplies an explicit +`CodeEditorContributionDescriptors` set; there is no editor-role field or +role-based feature branch. Public `Viewer` selects `standalone`, both diff +panes select `diff_pane`, and an embedded Peek source preview selects +`peek_preview`. Standalone enables the complete code contribution set. Diff +panes retain agent feedback, folding, hover, context menu, Definition, and +References, but omit Markdown-comment rich replacement to preserve source-line +geometry and omit quick diff to prevent a nested diff. Peek previews retain +hover, context menu, and direct Definition only, so they cannot recursively +open References Peek, render Markdown comments, or attach quick diff. Runtime +availability is always determined by whether the corresponding typed +contribution was instantiated. + +Presentation selection belongs to desktop and embedded hosts. The usual policy +is a decoded lowercase `.md` path or exact `markdown` language id for +`MarkdownViewer`, with `.mbt.md` selecting `MoonBitMarkdown`; all other files +use `Viewer`. A comparison always uses `DiffEditor`. + +Each surface borrows caller-owned models and an explicitly supplied service +bundle. Its mounted constructor owns the renderer subtree and scheduled work, +not the host element. Clearing or replacing a model detaches it synchronously; +Markdown and diff setters return their fully detached old model value so the +host can release it immediately. Disposal is idempotent and never disposes +caller-owned models, hosts, or services. The host owns files, transport, persistence, reload policy, shell chrome, and error presentation. The viewer owns readonly rendering, selection, scrolling, @@ -101,6 +106,9 @@ reference_host: editor reference composition { facade: viewer facade inner: internal/viewer { grid-columns: 1 + code_editor: code_editor_widget + diff_editor: diff_editor + markdown_viewer: markdown_viewer view: browser/view contrib: contrib/* markdown: markdown @@ -191,8 +199,8 @@ The root `viewer` facade and public `viewer/browser` contract package are js-only. Concrete browser runtime packages live below the module-private `internal/viewer/**` boundary: -- `viewer` is the opaque public facade, the Monaco `CodeEditorWidget` and - `editor.api.ts` role. Public `Viewer` construction is only `Viewer::create`; +- `viewer` is the opaque public façade for `Viewer`, `MarkdownViewer`, and + `DiffEditor`. Public `Viewer` construction is only `Viewer::create`; precomputed reference locations enter only through `Viewer::show_references`, while the provider-backed References contribution consumes only the opaque `LanguageHandle`; `ViewerOptions`, @@ -200,30 +208,23 @@ js-only. Concrete browser runtime packages live below the module-private generated interfaces may reference language/common values, browser contracts, and common capability handles, never private view or contribution implementations. -- `viewer` also exposes opaque `DiffViewer` and `DiffEditorModel` facades. A - DiffViewer installs two ordinary child Viewers into one stable caller-owned - host, borrows the two caller-owned models, and shares one explicit - `ViewerServices` bundle. The root coordinator is the only owner of diff - mappings, line/character decoration ids, ViewZone ids, model-change - subscriptions, render mode, and coupled scroll subscriptions. Both children - stay unwrapped, unfolded code presentations (including raw `.md` source). - `SideBySide` turns each mapping's height deficit into an alignment ViewZone - after the shorter range. `Unified` hides only the original presentation and - projects tokenized original deletion lines into the full-width modified - Viewer as content/margin ViewZones; modified lines retain their ordinary - Viewer decorations and interactions. `set_render_mode` rebuilds only those - coordinator-owned artifacts, so models and modified view state remain - installed. Language and feedback behavior stays model-scoped in the ordinary - Viewer path. Hosts own revision routing: a historical model must not match a - provider that can answer only for the live checkout. The stylesheet source is - `viewer/browser/diff_editor/diff_editor.css`. -- `viewer` retains the independent opaque `UnifiedDiffView` compatibility - facade. It accepts bounded caller-owned strings and eagerly renders the - `viewer/common/unified_diff` result through - `internal/viewer/browser/unified_diff`; its stylesheet remains - `viewer/browser/unified_diff/unified_diff.css`. It does not participate in - the two-Viewer composition and is no longer OpenSeek's primary review - surface. +- `internal/viewer/code_editor_widget` is the canonical Monaco + `CodeEditorWidget`-shaped implementation. Public `Viewer` owns one kernel and + forwards the standalone code API. The internal package cannot import public + `viewer`, `diff_editor`, or Markdown document packages, and it exports only + stable coordinator primitives rather than raw configuration, `ViewModel`, + `View`, or `MouseHandler` handles. +- `internal/viewer/diff_editor` owns `DiffEditorWidget`, + `DiffEditorEditors`, one shared `DiffEditorViewModel`, diff decorations, + managed alignment zones, stable scroll restoration, overview, layout, and + navigation. `DiffEditorEditors` creates two code kernels directly; there is + no public `Viewer` in this ownership graph. The public layouts are + `SideBySide` and `Inline`; no compatibility renderer or adapter layer is + retained. +- `internal/viewer/markdown_viewer` owns the standalone rich Markdown widget + and its model-scoped feature bridges. Public `MarkdownViewer` projects a + shared `ViewerServices` bundle into its narrower service contract and keeps + Markdown model/options/view-state types out of the code kernel. - `viewer/browser` owns canonical editor mouse events, target kinds, DOM coordinates, the mutable live ViewZone descriptor/opaque accessor contract, and the opaque unmanaged overlay-widget handle. @@ -289,10 +290,12 @@ js-only. Concrete browser runtime packages live below the module-private `internal/viewer/browser/view` owns closed event/render handle enums, rendering contexts, concrete view parts, and their lifecycle wiring. Files in that -directory share private declarations as one compilation unit. Root -`viewer/*.mbt` contains the `Viewer::` composition glue: the internal browser -packages are lower-level dependencies and cannot import the root facade -without reversing the dependency and creating a package cycle. +directory share private declarations as one compilation unit. +`internal/viewer/code_editor_widget/*.mbt` contains the +`CodeEditorWidget::` composition glue. Root `viewer/*.mbt` is only the opaque +public facade and forwards standalone code-editor operations to one kernel; +internal browser packages cannot import that facade without reversing the +dependency and creating a package cycle. ### Contributions @@ -317,37 +320,37 @@ editor common/browser layers; editor common never depends on them. - `internal/viewer/contrib/definition` owns DOM-free definition-result normalization, token fingerprints, and Ctrl/Cmd-link/request state. `internal/viewer/contrib/definition/browser` owns only Definition's - non-destructive-message DOM shell. Root Definition composition owns provider - requests, cancellation, Definition-specific no-result/open-rejection - feedback, and Code decorations or projected Markdown link spans. It - populates the shared References controller after an Alt+F12 result set or - multiple ordinary results are already known. The Markdown adapter resolves - native pointer geometry through the document-owned source map and never - creates a virtual model. The emitted + non-destructive-message DOM shell. `CodeEditorWidget` composition owns Code + provider requests, cancellation, Definition-specific no-result/open- + rejection feedback, and editor decorations. It populates the shared + References controller after an Alt+F12 result set or multiple ordinary + results are already known. Markdown definition projection is owned by + `MarkdownViewerWidget`; its adapter resolves native pointer geometry through + the document-owned source map and never creates a virtual model. The emitted stylesheet remains at `viewer/contrib/definition/browser/definition.css`. - `internal/viewer/contrib/references` owns the DOM-free grouped result, snippet, navigation, mode, phase, and exact source-session values shared by Definition and References Peek. Its browser sibling owns only the detached mode-labelled shell and feature-local ARIA tree. The tree is native content inside the shared `internal/viewer/ui/scrollbar` DOM/input lifetime, while - retaining tree focus and keyboard ownership. Root `viewer` owns the one - shared per-Viewer controller, the Shift+F12/context-menu provider action, - provider-query cancellation and freshness, `Viewer::show_references`, - per-group lazy cancellation/reference slots, the selected nested - Viewer/reference and decorations, presentation mounts, Current/Side opener - dispatch, and atomic teardown. Browser callbacks carry typed result - identities upward; neither internal package imports the root facade or - resolves a model. + retaining tree focus and keyboard ownership. Each `CodeEditorWidget` owns + one shared controller, the Shift+F12/context-menu provider action, + provider-query cancellation and freshness, the internal implementation of + `Viewer::show_references`, per-group lazy cancellation/reference slots, the + selected nested code kernel/reference and decorations, Peek mounts, + Current/Side opener dispatch, and atomic teardown. Browser callbacks carry + typed result identities upward; no internal browser/contribution package + imports the public facade or resolves a model. The shared Peek/tree stylesheet remains at `viewer/contrib/references/browser/references.css`. - `internal/viewer/contrib/contextmenu/browser` owns the reusable detached HTML menu shell, focus return, temporary document/window listeners, submenu - timers, ARIA state, and viewport fitting. Root `viewer` owns the per-Viewer - contribution, live command/menu resolution, target filtering, - cursor/selection policy, and semantic Markdown source anchor. Code text or - empty-content hits and valid semantic Markdown rows use the custom menu; - injected text, editor widgets, margins, scrollbars, ordinary Markdown, and - unavailable command sets retain the browser-native menu. The emitted + timers, ARIA state, and viewport fitting. `CodeEditorWidget` owns the + per-editor contribution, live command/menu resolution, target filtering, and + cursor/selection policy. Code text or empty-content hits use the custom menu; + injected text, editor widgets, margins, scrollbars, and unavailable command + sets retain the browser-native menu. Markdown interactions belong to + `MarkdownViewerWidget` and do not enter this code contribution. The emitted stylesheet remains at `viewer/contrib/contextmenu/browser/contextmenu.css`. - `internal/viewer/contrib/agent_feedback` owns concrete feedback @@ -365,7 +368,7 @@ editor common/browser layers; editor common never depends on them. - `internal/viewer/contrib/folding/browser` currently owns the complete js-only folding implementation, including ranges, hidden areas, decorations, controller, and the language-neutral outline-body range presentation used by - explicit host outlines. The root Viewer computes MoonBit function-body + explicit host outlines. `CodeEditorWidget` computes MoonBit function-body ranges without importing a concrete syntax package; ordinary provider/user folding remains the Monaco behavior port. Its emitted stylesheet remains at `viewer/contrib/folding/browser/folding.css`. @@ -375,8 +378,8 @@ editor common/browser layers; editor common never depends on them. visible/offscreen size observer. The root contribution owns one instance of the shared Markdown diagram-viewport group per rendered target and disposes it before renderer replacement. Inline viewport-height changes report only - through a narrow callback into the existing size observer. The root - `viewer` contribution owns reconciliation, renderer and viewport lifetimes, + through a narrow callback into the existing size observer. The code-widget + contribution owns reconciliation, renderer and viewport lifetimes, exact model-source extraction, zone ids, and the one hidden-area source. Its emitted stylesheet remains at `viewer/contrib/markdown_comments/browser/markdown_comments.css`. @@ -391,11 +394,12 @@ Removal restores the caller node's original `aria-hidden` presence and value. The root editor registry has two distinct ownership layers. Its process-wide contribution-description table contains constructors only; the adjacent command, keybinding, and closed menu-placement tables likewise contain no -per-Viewer state. -`Viewer.contributions` is an `EditorContributions` owner whose `instances` map -is the sole lookup table for that Viewer's eight concrete contribution entries; -feature packages do not keep second maps keyed by editor id. Root `Viewer::` -helpers recover typed controllers by matching both the fixed id and central +per-kernel state. +`CodeEditorWidget.contributions` is an `EditorContributions` owner whose +`instances` map is the sole lookup table for that kernel's concrete +contribution entries; +feature packages do not keep second maps keyed by editor id. Kernel helpers +recover typed controllers by matching both the fixed id and central entry variant, mirroring Monaco's typed view over `CodeEditorContributions._instances`. @@ -410,12 +414,12 @@ are released at their later root cleanup slot before the map is cleared. Declared instantiation modes are retained for source parity, but all modes currently instantiate eagerly. -The Markdown hover bridge is not a central Code contribution. It is -owned by `MarkdownBrowserData`, is activated only after the corresponding -`ModelData` becomes current, and is cancelled before any source/theme -projection replacement. Detach retires the bridge and renderer while the root -is still mounted, removes the now-inert presentation root, and only then -releases model listeners, the attached-view handle, and the `ViewModel`. +The Markdown hover bridge is not a Code contribution. It is owned by the +current `MarkdownViewerWidgetAttachment`, is activated only after that +attachment becomes current, and is cancelled before any source/theme +projection replacement. Detach retires the bridge and renderer while the +Markdown root is still mounted, removes the inert root, and then releases its +model listeners. The Markdown-comment entry owns Viewer-lifetime model/content subscriptions and a model-scoped stable-key map. A mounted Viewer replaces normalized comment @@ -452,7 +456,7 @@ teardown orders toggle, viewport, renderers, and observer disposal before zone removal. The only diagram-to-ViewZone invalidation chain runs from the viewport-height callback through `MarkdownCommentSizeObserver::request_measure` to -`Viewer::apply_markdown_comment_height`; the final step keeps the existing +`CodeEditorWidget::apply_markdown_comment_height`; the final step keeps the existing generation and zone-id checks and remains the sole writer of the live ViewZone height. @@ -544,8 +548,9 @@ so excluding them would leave it unbuildable. events. Its private MoonBit Markdown-comment provider adapts exact `///|` item anchors and their following `///` documentation into the Viewer's language-neutral provider contract. - It installs every document through the same `Viewer::set_model` path; `.md` - and `.mbt.md` require no shell-side presentation branch. Remote hover and + It explicitly selects public `Viewer` or `MarkdownViewer` before installing + a document; `.mbt.md` also selects `MoonBitMarkdown` resource semantics. + Remote hover and diagnostics are accepted only for the exact current model identity, version, URI, revision, and content generation, so the Markdown bridge receives the same original-model freshness guarantees as Code. @@ -568,6 +573,11 @@ script: framework packages. The reference shell owns the app framework. - Browser helper packages do not import the parent `viewer` facade. - `viewer/common/**` does not import `internal/viewer/**`. +- `internal/viewer/diff_editor` depends on + `internal/viewer/code_editor_widget`, never on public `Viewer`. +- `internal/viewer/code_editor_widget` does not import public `viewer`, + `diff_editor`, or Markdown presentation packages, and does not export raw + configuration, `ViewModel`, `View`, or `MouseHandler` accessors. - The root `viewer` package is the external browser facade; embedders use it and public `viewer/common/**` contracts rather than private browser/contribution implementation packages. The embedded example keeps this surface compiling. @@ -595,10 +605,10 @@ maps the model URI to a server-side workspace path. ```mermaid flowchart LR - subgraph PM["Presentation-specific pointer mapping"] - PTR["Pointer coordinates"] --> PRES{"Active presentation"} - PRES -->|Code| CODE["Browser caret hit
+ retained CharacterMapping"] - PRES -->|Markdown| MD["Browser caret hit
+ retained semantic source boundaries"] + subgraph PM["Surface-specific pointer mapping"] + PTR["Pointer coordinates"] --> PRES{"Selected public surface"} + PRES -->|Viewer| CODE["Browser caret hit
+ retained CharacterMapping"] + PRES -->|MarkdownViewer| MD["Browser caret hit
+ retained semantic source boundaries"] CODE --> VP["1-based view Position"] VP --> CONV["CoordinatesConverter"] CONV --> MP["1-based model Position"] @@ -617,7 +627,7 @@ flowchart LR FRESH --> UI["Hover widget + range decoration"] ``` -There are two presentation-specific entrances and one shared semantic path: +There are two surface-specific entrances and one shared semantic path: - **Code** uses the browser caret API and the `CharacterMapping` retained by each rendered `ViewLine`. It first produces a view position, then converts @@ -638,7 +648,7 @@ represented by an ordinary integer or `Position`. | Space | Shape and unit | Owner | Conversion boundary | | --- | --- | --- | --- | | Pointer geometry | client/page/editor-relative CSS pixels | browser controller | Browser event coordinates are normalized before hit testing. | -| DOM caret | token span plus a normalized 0-based UTF-16 text offset | active browser presentation | The browser caret API answers a DOM boundary, not a source location. | +| DOM caret | token span plus a normalized 0-based UTF-16 text offset | selected code or Markdown surface | The browser caret API answers a DOM boundary, not a source location. | | View position | 1-based UTF-16 `Position` in rendered view lines | Code `View`/`ViewModel` | `CharacterMapping` maps the token span and DOM offset to a view column. Markdown does not use this space. | | Model position | 1-based UTF-16 `Position` in the original source model | `TextModel` | `CoordinatesConverter` removes wrapping, folding, and injected-text projection. | | Model offset | 0-based UTF-16 offset from the start of the original document | `TextSnapshot` | `TextModel::get_offset_at` and `get_position_at` are the only position/offset authorities. | @@ -653,7 +663,7 @@ Unicode character can therefore occupy two columns/offset units. No stage may silently reinterpret these values as UTF-8 byte offsets or Unicode scalar indices. -### Code presentation: DOM caret to model position +### Code surface: DOM caret to model position For Code, the pointer controller performs the following steps: @@ -669,8 +679,9 @@ For Code, the pointer controller performs the following steps: `CharacterMapping` for the view column. The mapping was produced together with the token-span HTML and retained with the `ViewLine`; it is not reconstructed from CSS geometry or token text after the event. -4. The resulting `MouseTarget` is still in view coordinates. At the root - Viewer event boundary, `convert_view_to_model_mouse_target` rebuilds every +4. The resulting `MouseTarget` is still in view coordinates. At the + `CodeEditorWidget` event boundary, `convert_view_to_model_mouse_target` + rebuilds every position-bearing target arm through the active `CoordinatesConverter`. This is where wrapped view rows, folded source lines, and injected text collapse back to a position in the original model. @@ -680,10 +691,10 @@ For Code, the pointer controller performs the following steps: yet the semantic token range eventually returned by the provider. The critical invariant is that a public editor mouse target and a hover anchor -are already in **model space**. Code above the Viewer boundary must never treat +are already in **model space**. Code above the kernel boundary must never treat them as view coordinates or apply the conversion a second time. -### Markdown presentation: DOM caret to model position +### Markdown surface: DOM caret to model position Markdown is rendered as a semantic document rather than as Code `ViewLine` rows, so reusing Code hit testing would invent the wrong coordinate space. A @@ -698,15 +709,15 @@ source-bearing semantic rows. The Markdown hover bridge: Ordinary prose, synthetic indentation, mismatched projections, and unsafe DOM zones fail closed rather than guessing a source location. The bridge is owned -by the active Markdown presentation and is cancelled before source, theme, or -projection replacement. +by the current `MarkdownViewerWidget` attachment and is cancelled before +source, theme, or projection replacement. ### Model position to language provider `MarkdownHoverParticipant` queries providers at the start of the model-space anchor range. Here, "Markdown" describes the hover content format, not the -Markdown document presentation: the same participant is used after either -presentation has found a model anchor. The `Languages` query surface is +public Markdown surface: the same participant is used after either surface has +found a model anchor. The `Languages` query surface is offset-addressed, so it first calls `TextModel::get_offset_at`. The registry then calls `TextModel::get_position_at` before invoking the provider because the two contracts intentionally differ: diff --git a/editor/internal/shell/examples/embedded_viewer/README.md b/editor/internal/shell/examples/embedded_viewer/README.md index 49f189536..5cce6e637 100644 --- a/editor/internal/shell/examples/embedded_viewer/README.md +++ b/editor/internal/shell/examples/embedded_viewer/README.md @@ -11,7 +11,9 @@ or WebSocket participates. ```mermaid flowchart LR - E["embedded_viewer example"] --> V["viewer"] + E["embedded_viewer example"] --> V["Viewer (code)"] + E --> M["MarkdownViewer"] + E --> D["DiffEditor"] E --> CM["viewer/common/{model,languages,editor_api,capability APIs}"] E -.->|"deliberately never"| S["internal/shell/**"] ``` @@ -24,33 +26,43 @@ forces embedders through a private package breaks the build here first. // The whole embedding surface an external host needs. let viewer = @viewer.Viewer::create(host) viewer.set_model(Some(@model.TextModel(uri, name, "moonbit", 1, "rev-1", text))) + +let markdown = @viewer.MarkdownViewer::create(markdown_host) +markdown.set_model(Some(@viewer.MarkdownViewerModel(model, OrdinaryMarkdown))) + +let diff = @viewer.DiffEditor::create(diff_host) +diff.set_model(Some(@viewer.DiffEditorModel(original, modified))) ``` ## Flow - Startup registers the MoonBit tokenizer in the default `Languages` registry. - `FileTree.on_open` asks the in-memory host for a new - `viewer/common/model.TextModel`, calls `Viewer::set_model`, then invokes the - separate `Viewer::handle_initialized` boundary after synchronous model - setup. That same path accepts Code, `.md`, and `.mbt.md` models; the Viewer - owns automatic presentation selection, so the embed has no Markdown parser - or presentation adapter. -- `Viewer::on_did_change_model` captures the attached URI and schedules one - native animation frame after the Viewer has queued its own DOM flush. The - callback rechecks the current model URI, drops stale swaps, then drives - `FileTree::set_active` (`autoReveal`) and the `ready` status. -- Rabbita renders a stable, childless `.viewer-host`; after the first paint the - host mounts the imperative editor with `Viewer::create`. + `viewer/common/model.TextModel`. Lowercase `.md` paths or the `markdown` + language id go to a dedicated `MarkdownViewer`; `.mbt.md` selects + `MoonBitMarkdown`. Every other model goes to the code-only `Viewer`. +- Each surface captures the attached URI and schedules one native animation + frame after its own DOM work. The callback rechecks the current model URI, + drops stale swaps, then drives `FileTree::set_active` (`autoReveal`) and the + `ready` status. +- `DiffEditor` receives an atomic original/modified pair. A real replacement + returns the fully detached old pair for immediate disposal; an exact same-pair + no-op returns `None`, so active models are never retired accidentally. +- Rabbita renders stable, childless code, Markdown, and diff hosts; after the + first paint the host mounts the imperative surfaces through their independent + public facades. This is the standalone-host boundary: the host owns storage, model creation, selection, and feature registration; the viewer owns its DOM subtree. `public_api_contract.mbt` is referenced but not executed; it keeps the opaque -options/services facade, common capability handles, root widget/zone factories, -and container/view DOM contracts compiling without importing browser internals. +options/services facades, common capability handles, root widget/zone factories, +and independent Code/Markdown/Diff contracts compiling without importing +browser internals. ## Validation `just dist-front-end` emits `web/dist/embed.{html,mjs}`. The host server serves -`/embed.html`; `tests/browser/smoke/embed.spec.js` covers Code and in-memory -Markdown rendering, lazy expansion, navigation, stale ready callbacks, and the -absence of a WebSocket. +`/embed.html`; `tests/browser/smoke/embed.spec.js` covers explicit Code and +Markdown routing, DiffEditor responsive/Inline/navigation/accessibility +contracts, lazy expansion, stale ready callbacks, and the absence of a +WebSocket. diff --git a/editor/internal/shell/examples/embedded_viewer/main.mbt b/editor/internal/shell/examples/embedded_viewer/main.mbt index 9e4f05f49..028c7c751 100644 --- a/editor/internal/shell/examples/embedded_viewer/main.mbt +++ b/editor/internal/shell/examples/embedded_viewer/main.mbt @@ -67,10 +67,15 @@ fn memory_workspace() -> MemoryWorkspace { memory_file("broken.mbt", "broken.mbt"), memory_file("comments-only.mbt", "comments-only.mbt"), memory_file("comments.mbt", "comments.mbt"), + memory_file("alignment-zones.mbt", "alignment-zones.mbt"), + memory_file("fragmented-large.mbt", "fragmented-large.mbt"), memory_file("large.mbt", "large.mbt"), + memory_file("navigation-anchors.mbt", "navigation-anchors.mbt"), memory_file("tabbed-deletion.mbt", "tabbed-deletion.mbt"), memory_file("oversized-line.mbt", "oversized-line.mbt"), memory_file("README.md", "README.md"), + memory_file("Guide.MD", "Guide.MD"), + memory_file("tour.MBT.MD", "tour.MBT.MD"), memory_file("moon.mod", "moon.mod"), ] directories["\{memory_root}/src"] = [ @@ -89,18 +94,38 @@ fn memory_workspace() -> MemoryWorkspace { "fn review_value() -> Int {", "", " // updated explanation", " new_value()", "}", ].join("\n") + documents["\{memory_root}/alignment-zones.mbt"] = alignment_diff_document( + false, + ) documents["\{memory_root}/large.mbt"] = large_diff_document("modified") + documents["\{memory_root}/fragmented-large.mbt"] = fragmented_deletion_document( + false, + ) + documents["\{memory_root}/navigation-anchors.mbt"] = [ + "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") documents["\{memory_root}/tabbed-deletion.mbt"] = "let retained = 1\n" documents["\{memory_root}/oversized-line.mbt"] = oversized_diff_line("m") documents["\{memory_root}/README.md"] = ( #|# Embedded Markdown document #| - #|This URI-backed in-memory model is rendered directly by the public **Viewer**. + #|This URI-backed in-memory model is rendered directly by the public **MarkdownViewer**. #| - #|- No workbench presentation branch participates. + #|- The embedding host selects the Markdown presentation explicitly. #|- No remote protocol or WebSocket participates. #| ) + documents["\{memory_root}/Guide.MD"] = "# Uppercase Markdown\n" + documents["\{memory_root}/tour.MBT.MD"] = ( + #|# MoonBit Markdown + #| + #|```mbt check + #|let answer = 42 + #|``` + #| + ) documents["\{memory_root}/moon.mod"] = "name = \"embedded-example\"\n" documents["\{memory_root}/src/main.mbt"] = ( #|///| @@ -124,9 +149,20 @@ fn memory_workspace() -> MemoryWorkspace { original_documents["\{memory_root}/comments.mbt"] = [ "fn review_value() -> Int {", " // obsolete explanation", " old_value()", "}", ].join("\n") + original_documents["\{memory_root}/alignment-zones.mbt"] = alignment_diff_document( + true, + ) original_documents["\{memory_root}/large.mbt"] = large_diff_document( "original", ) + original_documents["\{memory_root}/fragmented-large.mbt"] = fragmented_deletion_document( + true, + ) + original_documents["\{memory_root}/navigation-anchors.mbt"] = [ + "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") original_documents["\{memory_root}/tabbed-deletion.mbt"] = "\t".repeat(160) + "deleted_tail\n" original_documents["\{memory_root}/oversized-line.mbt"] = oversized_diff_line( @@ -135,7 +171,16 @@ fn memory_workspace() -> MemoryWorkspace { original_documents["\{memory_root}/README.md"] = ( #|# Embedded document #| - #|This in-memory model is rendered by the public Viewer. + #|This in-memory model is rendered by the public MarkdownViewer. + #| + ) + original_documents["\{memory_root}/Guide.MD"] = "# Old uppercase Markdown\n" + original_documents["\{memory_root}/tour.MBT.MD"] = ( + #|# Old MoonBit Markdown + #| + #|```mbt check + #|let answer = 41 + #|``` #| ) original_documents["\{memory_root}/moon.mod"] = "" @@ -163,7 +208,7 @@ fn memory_workspace() -> MemoryWorkspace { ///| /// A valid sub-1-MiB source pair with enough short changed lines to exercise -/// the DiffViewer's ordinary Viewer virtualization. +/// the DiffEditor's two code-editor kernels and viewport virtualization. fn large_diff_document(prefix : String) -> String { let content = StringBuilder() for index in 0..<2600 { @@ -172,6 +217,142 @@ fn large_diff_document(prefix : String) -> String { content.to_string() } +///| +/// Twenty independent 130-line deletions separated by stable anchors. This +/// prevents a renderer from satisfying the DOM budget only for one giant hunk +/// while eagerly retaining every individually short deleted block. +fn fragmented_deletion_document(original : Bool) -> String { + let content = StringBuilder() + for fragment in 0..<20 { + if original { + for line in 0..<130 { + content.write_string( + "let fragment_\{fragment}_deleted_\{line} = \{line}\n", + ) + } + } + content.write_string("let fragment_\{fragment}_retained = \{fragment}\n") + } + content.to_string() +} + +///| +/// A valid MoonBit-shaped source pair with two independently measured +/// Markdown comment ViewZones and visible edits near the top, middle, and +/// bottom. Comment source line counts stay paired while their rendered pixel +/// heights differ; later code hunks also change logical line counts. +fn alignment_diff_document(original : Bool) -> String { + let content = StringBuilder() + content.write_string("fn alignment_fixture() -> Int {\n") + for index in 0..<700 { + if index == 20 { + content.write_string(" //~ ## First rendered note\n") + content.write_string( + if original { + " //~ This original-side paragraph is deliberately long enough to wrap across several rendered rows in a narrow diff pane, so its external ViewZone has a larger measured pixel height than the modified-side note.\n" + } else { + " //~ Short modified note.\n" + }, + ) + content.write_string(" //~ Shared final sentence.\n") + } else if index == 180 { + content.write_string(" //~ ## Second rendered note\n") + content.write_string( + if original { + " //~ Short original note.\n" + } else { + " //~ This modified-side paragraph is deliberately long enough to wrap across several rendered rows in a narrow diff pane, reversing which child owns the taller external ViewZone.\n" + }, + ) + content.write_string(" //~ Shared final sentence.\n") + } else if index == 60 { + content.write_string(" //~ ## Shared note before an insertion\n") + content.write_string(" //~ Shared note body.\n") + if !original { + content.write_string(" //~ Inserted documentation line.\n") + content.write_string(" //~ Another inserted documentation line.\n") + content.write_string(" let inserted_after_comment_60 = 60\n") + } + } else if index == 300 { + content.write_string(" let replacement_300 = 300\n") + if !original { + content.write_string(" let inserted_301 = 301\n") + content.write_string(" let inserted_302 = 302\n") + } + } else if index == 480 { + content.write_string(" let replacement_480 = 480\n") + if original { + content.write_string(" let deleted_481 = 481\n") + content.write_string(" let deleted_482 = 482\n") + content.write_string(" let deleted_483 = 483\n") + } + } else if index == 80 { + content.write_string( + if original { + " let changed_80 = 80\n" + } else { + " let changed_80 = 800\n" + }, + ) + } else if index == 120 { + content.write_string(" let wide_120 = \"" + "x".repeat(400) + "\"\n") + } else if index == 640 { + content.write_string( + if original { + " let changed_640 = 640\n" + } else { + " let changed_640 = 6400\n" + }, + ) + } else { + content.write_string(" let stable_\{index} = \{index}\n") + } + } + content.write_string(" 0\n}\n") + content.to_string() +} + +///| +priv struct EmbeddedAlignmentMarkdownProvider {} + +///| +impl @language.MarkdownCommentProvider for EmbeddedAlignmentMarkdownProvider with fn provide_markdown_comments( + _self, + model, +) { + if !model.uri.path.has_suffix("/alignment-zones.mbt") { + return [] + } + let snapshot = model.snapshot() + let blocks : Array[@language.MarkdownCommentBlock] = [] + let mut line_number = 1 + while line_number <= snapshot.line_count() { + if !snapshot.get_line_content(line_number).trim().has_prefix("//~") { + line_number += 1 + continue + } + let start_line_number = line_number + let markdown : Array[String] = [] + while line_number <= snapshot.line_count() { + let line = snapshot.get_line_content(line_number).trim().to_owned() + if !line.has_prefix("//~") { + break + } + let mut start = 3 + if start < line.length() && line[start].to_int() == 32 { + start += 1 + } + markdown.push(line.unsafe_substring(start~, end=line.length())) + line_number += 1 + } + blocks.push({ + line_range: LineRange(start_line_number, line_number), + markdown: markdown.join("\n"), + }) + } + blocks +} + ///| /// Each side is a single valid source line below the desktop's 1 MiB read /// limit, while their combined UTF-16 size exceeds the legacy eager-diff budget. @@ -239,11 +420,36 @@ fn MemoryWorkspace::model( } } +///| +priv enum EmbedSourcePresentation { + Code + Markdown +} derive(Eq) + +///| +fn EmbedSourcePresentation::label(self : EmbedSourcePresentation) -> String { + match self { + Code => "code" + Markdown => "markdown" + } +} + +///| +fn embed_source_presentation(uri : @base_common.Uri) -> EmbedSourcePresentation { + if uri.path.to_lower().has_suffix(".md") { + Markdown + } else { + Code + } +} + ///| priv struct EmbedModel { status : String diff_visible : Bool - unified_render : Bool + inline_layout : Bool + sash_enabled : Bool + source_presentation : EmbedSourcePresentation } derive(Eq) ///| @@ -254,6 +460,7 @@ priv enum EmbedMsg { ViewerFailed ToggleDiff ToggleDiffLayout + ToggleDiffSash } ///| @@ -271,18 +478,21 @@ fn dispatch_embed_msg(msg : EmbedMsg) -> Unit { let embed_viewer_cell : Ref[@viewer.Viewer?] = Ref(None) ///| -let embed_diff_view_cell : Ref[@viewer.DiffViewer?] = Ref(None) +let embed_markdown_viewer_cell : Ref[@viewer.MarkdownViewer?] = Ref(None) + +///| +let embed_diff_editor_cell : Ref[@viewer.DiffEditor?] = Ref(None) ///| -/// Resolves the embedded viewer, creating it into the shell-rendered -/// `.viewer-host` element on first use. First use happens in the boot +/// Resolves the embedded code viewer, creating it into the shell-rendered +/// `.code-viewer-host` element on first use. First use happens in the boot /// after-render command, so the host element is always in the DOM by then. fn embed_viewer() -> @viewer.Viewer { match embed_viewer_cell.val { Some(viewer) => viewer None => { let host = @rdom.document() - .query_selector(".viewer-host") + .query_selector(".code-viewer-host") .to_option() .unwrap() let viewer = @viewer.Viewer::create( @@ -312,35 +522,122 @@ fn embed_viewer() -> @viewer.Viewer { } } +///| +/// The rich Markdown surface is a separate public widget. The embedding host, +/// rather than Viewer, owns presentation selection. +fn embed_markdown_viewer() -> @viewer.MarkdownViewer { + match embed_markdown_viewer_cell.val { + Some(viewer) => viewer + None => { + let host = @rdom.document() + .query_selector(".markdown-viewer-host") + .to_option() + .unwrap() + let viewer = @viewer.MarkdownViewer::create( + host, + options=MarkdownViewerOptions(placeholder="Loading Markdown..."), + ) + viewer.on_did_change_model(event => { + guard event.new_model_url is Some(attached_uri) else { return } + embed_ready_animation_frame(() => { + match viewer.get_model() { + Some(current) => + if current.model.uri.to_string() == attached_uri.to_string() { + dispatch_embed_msg(ViewerReady(attached_uri)) + } + None => () + } + }) + }) + |> ignore + embed_markdown_viewer_cell.val = Some(viewer) + viewer + } + } +} + +///| +fn replace_embed_code_model( + viewer : @viewer.Viewer, + model : @model.TextModel?, +) -> Unit { + let previous = viewer.get_model() + viewer.set_model(model) + match previous { + Some(previous) => + match model { + Some(current) if previous.same(current) => () + _ => previous.dispose() + } + None => () + } +} + +///| +fn replace_embed_markdown_model( + viewer : @viewer.MarkdownViewer, + model : @viewer.MarkdownViewerModel?, +) -> Unit { + match viewer.set_model(model) { + Some(previous) => + match model { + // A resource-kind-only replacement keeps the same active TextModel. + Some(current) if previous.model.same(current.model) => () + _ => previous.model.dispose() + } + None => () + } +} + +///| +fn embed_current_source_model() -> @model.TextModel? { + match embed_viewer_cell.val { + Some(viewer) if viewer.get_model() is Some(model) => return Some(model) + _ => () + } + match embed_markdown_viewer_cell.val { + Some(viewer) => viewer.get_model().map(model => model.model) + None => None + } +} + ///| /// Resolves the switchable public diff surface in its own stable host. Source -/// and diff views are siblings so toggling never replaces the source Viewer's -/// model, selection, or scroll state; render-mode changes stay inside the -/// DiffViewer. -fn embed_diff_view() -> @viewer.DiffViewer { - match embed_diff_view_cell.val { +/// and diff views are siblings. DiffEditor owns two internal code-editor +/// kernels and one shared diff ViewModel; it never creates a public Viewer. +fn embed_diff_editor() -> @viewer.DiffEditor { + match embed_diff_editor_cell.val { Some(view) => view None => { let host = @rdom.document() - .query_selector(".diff-viewer-host") + .query_selector(".diff-editor-host") .to_option() .unwrap() - let view = @viewer.DiffViewer::create(host) - embed_diff_view_cell.val = Some(view) + let view = @viewer.DiffEditor::create(host) + embed_diff_editor_cell.val = Some(view) view } } } ///| -fn dispose_embed_diff_models(view : @viewer.DiffViewer) -> Unit { - let models = view.get_model() - view.clear() - if models is Some(models) { - models.original.dispose() - if !models.modified.same(models.original) { - models.modified.dispose() - } +fn dispose_embed_diff_model_pair(models : @viewer.DiffEditorModel) -> Unit { + models.original.dispose() + if !models.modified.same(models.original) { + models.modified.dispose() + } +} + +///| +fn replace_embed_diff_model( + view : @viewer.DiffEditor, + model : @viewer.DiffEditorModel?, +) -> Unit { + // A same-pair no-op returns None, so the active models are never disposed. + // A real replacement returns a pair that is already fully detached. + match view.set_model(model) { + Some(previous) => dispose_embed_diff_model_pair(previous) + None => () } } @@ -349,19 +646,18 @@ fn set_embed_diff_visible(visible : Bool) -> @rabbita.Cmd { @cmd.custom_cmd( _ => { if !visible { - match embed_diff_view_cell.val { - Some(view) => dispose_embed_diff_models(view) + match embed_diff_editor_cell.val { + Some(view) => replace_embed_diff_model(view, None) None => () } return } - guard embed_viewer().get_model() is Some(model) && + guard embed_current_source_model() is Some(model) && workspace_backend.original_documents.get(model.uri.to_string()) is Some(original) else { return } - let diff_view = embed_diff_view() - dispose_embed_diff_models(diff_view) + let diff_editor = embed_diff_editor() let version = next_embed_model_version() let original_uri = @base_common.Uri::from( scheme="memory-original", @@ -386,21 +682,23 @@ fn set_embed_diff_visible(visible : Bool) -> @rabbita.Cmd { "modified-memory-\{version.to_string()}", model.get_value(), ) - diff_view.set_model(Some(DiffEditorModel(original_model, modified_model))) + replace_embed_diff_model( + diff_editor, + Some(DiffEditorModel(original_model, modified_model)), + ) }, kind=@cmd.after_render, ) } ///| -fn set_embed_diff_render_mode(unified : Bool) -> @rabbita.Cmd { +fn set_embed_diff_options(inline : Bool, sash_enabled : Bool) -> @rabbita.Cmd { @cmd.custom_cmd(_ => { - embed_diff_view().set_render_mode( - if unified { - Unified - } else { - SideBySide - }, + embed_diff_editor().update_options( + DiffEditorOptions( + layout=if inline { Inline } else { SideBySide }, + sash_enabled~, + ), ) }) } @@ -459,7 +757,12 @@ fn update_embed( set_embed_diff_visible(false), open_embed_document_cmd(uri), ]), - { ..model, status: "loading", diff_visible: false }, + { + ..model, + status: "loading", + diff_visible: false, + source_presentation: embed_source_presentation(uri), + }, ) ViewerReady(uri) => (embed_tree().set_active(uri), { ..model, status: "ready" }) @@ -479,10 +782,20 @@ fn update_embed( if model.status != "ready" || !model.diff_visible { (@rabbita.none, model) } else { - let unified_render = !model.unified_render + let inline_layout = !model.inline_layout + ( + set_embed_diff_options(inline_layout, model.sash_enabled), + { ..model, inline_layout, }, + ) + } + ToggleDiffSash => + if model.status != "ready" || !model.diff_visible { + (@rabbita.none, model) + } else { + let sash_enabled = !model.sash_enabled ( - set_embed_diff_render_mode(unified_render), - { ..model, unified_render, }, + set_embed_diff_options(model.inline_layout, sash_enabled), + { ..model, sash_enabled, }, ) } } @@ -492,11 +805,33 @@ fn update_embed( fn open_embed_document_cmd(uri : @base_common.Uri) -> @rabbita.Cmd { @cmd.custom_cmd(_ => { match workspace_backend.model(uri) { - Some(model) => { - let viewer = embed_viewer() - viewer.set_model(Some(model)) - viewer.handle_initialized() - } + Some(model) => + if model.uri.path.to_lower().has_suffix(".md") || + model.get_language_id() == "markdown" { + match embed_viewer_cell.val { + Some(viewer) => replace_embed_code_model(viewer, None) + None => () + } + let viewer = embed_markdown_viewer() + let resource_kind = if model.uri.path.to_lower().has_suffix(".mbt.md") { + @viewer.MarkdownResourceKind::MoonBitMarkdown + } else { + OrdinaryMarkdown + } + replace_embed_markdown_model( + viewer, + Some(MarkdownViewerModel(model, resource_kind)), + ) + viewer.handle_initialized() + } else { + match embed_markdown_viewer_cell.val { + Some(viewer) => replace_embed_markdown_model(viewer, None) + None => () + } + let viewer = embed_viewer() + replace_embed_code_model(viewer, Some(model)) + viewer.handle_initialized() + } None => dispatch_embed_msg(ViewerFailed) } }) @@ -512,6 +847,7 @@ fn view_embed( class="editor-shell", attrs=@html.Attrs::build() .data_set("status", model.status) + .data_set("source-presentation", model.source_presentation.label()) .data_set("theme", "dark"), [ @html.div(class="editor-main", [ @@ -544,18 +880,34 @@ fn view_embed( @html.button( type_="button", class="sidebar-action embed-diff-toggle", - title=if model.unified_render { + title=if model.inline_layout { "Use side-by-side diff layout" } else { - "Use unified diff layout" + "Use inline diff layout" }, disabled=!model.diff_visible, on_click=emit(ToggleDiffLayout), attrs=@html.Attrs::build() .data_set("action", "toggle-diff-layout") - .aria_label("Unified diff layout") - .aria_pressed(model.unified_render.to_string()), - "Unified", + .aria_label("Inline diff layout") + .aria_pressed(model.inline_layout.to_string()), + "Inline", + ), + @html.button( + type_="button", + class="sidebar-action embed-diff-toggle", + title=if model.sash_enabled { + "Disable diff pane sash" + } else { + "Enable diff pane sash" + }, + disabled=!model.diff_visible, + on_click=emit(ToggleDiffSash), + attrs=@html.Attrs::build() + .data_set("action", "toggle-diff-sash") + .aria_label("Diff pane sash") + .aria_pressed(model.sash_enabled.to_string()), + "Sash", ), ]), ]), @@ -566,18 +918,26 @@ fn view_embed( // Stable imperative-DOM hosts share the grid's content cell; the // shell never renders vdom children into either host. @html.div( - class=if model.diff_visible { - "viewer-host moonbit-viewer-surface-hidden" + class=if model.diff_visible || model.source_presentation == Markdown { + "viewer-host code-viewer-host moonbit-viewer-surface-hidden" } else { - "viewer-host" + "viewer-host code-viewer-host" + }, + ([] : Array[@html.Html]), + ), + @html.div( + class=if model.diff_visible || model.source_presentation == Code { + "viewer-host markdown-viewer-host moonbit-viewer-surface-hidden" + } else { + "viewer-host markdown-viewer-host" }, ([] : Array[@html.Html]), ), @html.div( class=if model.diff_visible { - "viewer-host diff-viewer-host" + "viewer-host diff-editor-host" } else { - "viewer-host diff-viewer-host moonbit-viewer-surface-hidden" + "viewer-host diff-editor-host moonbit-viewer-surface-hidden" }, ([] : Array[@html.Html]), ), @@ -593,6 +953,11 @@ fn main { // executing it in the example. The discarded function value keeps every // supported root/common signature checked by the real embedding package. let _ = compile_external_viewer_api_contract + @languages.languages.register_markdown_comment_provider( + LanguageId("moonbit"), + EmbeddedAlignmentMarkdownProvider::{ }, + ) + |> ignore // Highlighting needs no server: the language package is composed in // by import and registered through the viewer languages facade. @languages.languages.set_tokens_provider( @@ -608,7 +973,13 @@ fn main { let app = @rabbita.new(() => { let tree_view = embed_tree().view() let (model, emit) = @rabbita.create_state( - { status: "loading", diff_visible: false, unified_render: false }, + { + status: "loading", + diff_visible: false, + inline_layout: false, + sash_enabled: true, + source_presentation: Code, + }, update=(model, msg, emit) => { let (cmd, model) = update_embed(emit, msg, model) (model, cmd) diff --git a/editor/internal/shell/examples/embedded_viewer/moon.pkg b/editor/internal/shell/examples/embedded_viewer/moon.pkg index 340e1c6ba..38684cef2 100644 --- a/editor/internal/shell/examples/embedded_viewer/moon.pkg +++ b/editor/internal/shell/examples/embedded_viewer/moon.pkg @@ -1,5 +1,6 @@ import { "moonbitlang/editor/base/common" @base_common, + "moonbitlang/editor/language", "moonbitlang/editor/platform/log", "moonbitlang/editor/viewer", "moonbitlang/editor/viewer/common/agent_feedback_api", diff --git a/editor/internal/shell/examples/embedded_viewer/public_api_contract.mbt b/editor/internal/shell/examples/embedded_viewer/public_api_contract.mbt index 75ac8b555..c1c377b5d 100644 --- a/editor/internal/shell/examples/embedded_viewer/public_api_contract.mbt +++ b/editor/internal/shell/examples/embedded_viewer/public_api_contract.mbt @@ -1,6 +1,6 @@ -// Compile-only contract for the supported Viewer facade. The imports and -// inferred browser-owned values below keep the public embedding boundary -// checked by the real MoonBit package. +// Compile-only contract for the independent Viewer, MarkdownViewer, and +// DiffEditor facades. The imports and inferred browser-owned values below keep +// the public embedding boundary checked by the real MoonBit package. ///| fn contract_disposable() -> @base_common.Disposable { @@ -170,23 +170,90 @@ fn compile_external_viewer_api_contract( require_contract_element(viewer.get_container_dom_node()) require_contract_optional_element(viewer.get_dom_node()) - // The diff facade is the same external composition over two borrowed - // models and one optionally shared service bundle. + // Rich Markdown is a separate explicit surface, never a Viewer presentation + // union. The model carries its resource semantics at the host boundary. + let markdown_host = @rdom.document().create_element("div") + let markdown_options = @viewer.MarkdownViewerOptions::default() + .with_placeholder("Loading external Markdown model...") + .with_theme("light") + .with_show_unused(false) + let markdown_viewer = @viewer.MarkdownViewer::create( + markdown_host, + services~, + options=markdown_options, + ) + let markdown_model = @viewer.MarkdownViewerModel(model, MoonBitMarkdown) + let markdown_model_subscription = markdown_viewer.on_did_change_model( + require_contract_model_event, + ) + let markdown_content_subscription = markdown_viewer.on_did_change_model_content(_ => { + () + }, + ) + let markdown_scroll_subscription = markdown_viewer.on_did_scroll_change(() => { + () + }) + let markdown_dispose_subscription = markdown_viewer.on_did_dispose(() => ()) + markdown_viewer.set_model(Some(markdown_model)) |> ignore + // Exact same model + resource kind is a no-op and must not retire the active + // caller-owned model. + markdown_viewer.set_model(Some(markdown_model)) |> ignore + markdown_viewer.get_model() |> ignore + markdown_viewer.get_options() |> ignore + markdown_viewer.update_options(markdown_options) + let markdown_view_state = markdown_viewer.save_view_state() + markdown_viewer.restore_view_state(markdown_view_state) + markdown_viewer.reveal_range_in_center(Range(1, 1, 1, 1)) + markdown_viewer.toggle_section_at(0) |> ignore + markdown_viewer.collapsed_sections() |> ignore + markdown_viewer.get_id() |> ignore + markdown_viewer.get_content_height() |> ignore + markdown_viewer.get_content_width() |> ignore + require_contract_element(markdown_viewer.get_container_dom_node()) + require_contract_optional_element(markdown_viewer.get_dom_node()) + markdown_viewer.handle_initialized() + markdown_viewer.layout() + markdown_viewer.focus() + markdown_viewer.has_text_focus() |> ignore + markdown_viewer.set_model(None) |> ignore + markdown_model_subscription.dispose() + markdown_content_subscription.dispose() + markdown_scroll_subscription.dispose() + markdown_dispose_subscription.dispose() + markdown_viewer.dispose() + + // DiffEditor owns two internal code-editor kernels and one shared diff + // ViewModel. Algorithm selection remains a host/provider concern. let diff_host = @rdom.document().create_element("div") - let diff_viewer = @viewer.DiffViewer::create( + let diff_options = @viewer.DiffEditorOptions( + editor_options=options, + layout=SideBySide, + ) + let diff_editor = @viewer.DiffEditor::create( diff_host, services~, - options~, - render_mode=SideBySide, + options=diff_options, ) - diff_viewer.get_render_mode() |> ignore - diff_viewer.set_render_mode(Unified) - diff_viewer.set_model(Some(DiffEditorModel(model, model))) - diff_viewer.set_render_mode(SideBySide) - diff_viewer.get_model() |> ignore - diff_viewer.layout() - diff_viewer.clear() - diff_viewer.dispose() + let diff_subscription = diff_editor.on_did_update_diff(() => ()) + let diff_model = @viewer.DiffEditorModel(model, model) + diff_editor.set_model(Some(diff_model)) |> ignore + // Exact same pair returns None; callers therefore never dispose active + // models while handling a no-op replacement. + diff_editor.set_model(Some(diff_model)) |> ignore + diff_editor.get_model() |> ignore + diff_editor.get_diff() |> ignore + diff_editor.is_diff_up_to_date() |> ignore + diff_editor.get_layout() |> ignore + diff_editor.reveal_next_change() |> ignore + diff_editor.reveal_previous_change() |> ignore + diff_editor.update_options(diff_options.with_layout(Inline)) + diff_editor.focus() + diff_editor.layout() + // A real clear returns the already-detached pair to its caller. This + // compile-only contract intentionally leaves the borrowed model alive. + diff_editor.set_model(None) |> ignore + diff_subscription.dispose() + diff_editor.dispose() // Explicit bundles and concrete backings remain caller-owned. services.dispose() diff --git a/editor/internal/shell/workbench/app.mbt b/editor/internal/shell/workbench/app.mbt index 513567f49..5ad8cdcf4 100644 --- a/editor/internal/shell/workbench/app.mbt +++ b/editor/internal/shell/workbench/app.mbt @@ -75,6 +75,7 @@ priv enum WorkbenchViewerEvent { DidChangeMarkers(Array[@base_common.Uri]) DidBuildViewModel(@viewer_testing.ViewModelBuiltObservation) DidRenderModel(@viewer_testing.ModelRenderedObservation) + DidRenderMarkdown(@model.TextModel) DidResolveHover(@viewer_testing.HoverResolvedObservation) DidScroll(@editor_api.ScrollEvent) DidDispose @@ -112,6 +113,11 @@ fn dispatch_workbench_msg(msg : WorkbenchMsg) -> Unit { /// document provider and event subscriptions into the shell update loop. let viewer_cell : Ref[@viewer.Viewer?] = Ref(None) +///| +/// Rich Markdown is a sibling surface selected by this host. It is never a +/// presentation branch inside the code editor. +let markdown_viewer_cell : Ref[@viewer.MarkdownViewer?] = Ref(None) + ///| /// Workbench-only layout hooks live for the mounted page. Keeping the /// lifetimes here makes reinstallation idempotent for browser harnesses that @@ -204,6 +210,72 @@ fn workbench_viewer() -> @viewer.Viewer { } } +///| +fn workbench_markdown_viewer() -> @viewer.MarkdownViewer { + match markdown_viewer_cell.val { + Some(viewer) => viewer + None => { + let host = @rdom.document() + .query_selector(".viewer-host") + .to_option() + .unwrap() + let viewer = @viewer.MarkdownViewer::create( + host, + services=workbench_services().viewer, + options=MarkdownViewerOptions(theme=stored_theme()), + ) + markdown_viewer_cell.val = Some(viewer) + viewer + } + } +} + +///| +fn workbench_active_model() -> @model.TextModel? { + match markdown_viewer_cell.val { + Some(viewer) => + match viewer.get_model() { + Some(model) => Some(model.model) + None => workbench_viewer().get_model() + } + None => workbench_viewer().get_model() + } +} + +///| +fn layout_workbench_presentation() -> Unit { + workbench_viewer().layout() + match markdown_viewer_cell.val { + Some(viewer) => viewer.layout() + None => () + } +} + +///| +fn clear_workbench_model() -> Unit { + workbench_viewer().set_model(None) + match markdown_viewer_cell.val { + Some(viewer) => viewer.set_model(None) |> ignore + None => () + } + show_workbench_presentation("code") +} + +///| +extern "js" fn show_workbench_presentation(kind : String) -> Unit = + #| (kind) => { + #| const host = globalThis.document?.querySelector?.('.viewer-host'); + #| if (!host) return; + #| for (const node of host.children) { + #| const code = node.classList?.contains('monaco-editor') ?? false; + #| const markdown = + #| node.classList?.contains('moonbit-viewer-markdown-document') || + #| node.classList?.contains('moonbit-viewer-markdown-empty'); + #| if (code) node.hidden = kind !== 'code'; + #| if (markdown) node.hidden = kind !== 'markdown'; + #| } + #| } + ///| fn install_viewer_event_subscriptions(viewer : @viewer.Viewer) -> Unit { // Diagnostics counts live on the marker service (Monaco's @@ -321,14 +393,15 @@ fn mount_app() -> Unit { /// gives it keyboard focus (the viewer owns its own key handling). fn attach_workbench_viewer() -> Unit { let viewer = workbench_viewer() - install_workbench_resize_hooks(viewer) + install_workbench_resize_hooks() + show_workbench_presentation("code") viewer.focus() } ///| /// Keeps the Viewer layout synchronized with every host-box change, including /// Explorer splitter gestures that do not emit a window resize event. -fn install_workbench_resize_hooks(viewer : @viewer.Viewer) -> Unit { +fn install_workbench_resize_hooks() -> Unit { match viewer_host_resize_observer.val { Some(observer) => observer.dispose() None => () @@ -342,7 +415,7 @@ fn install_workbench_resize_hooks(viewer : @viewer.Viewer) -> Unit { return } viewer_host_resize_observer.val = @base_browser.observe_element_resize(host, () => { - viewer.layout() + layout_workbench_presentation() }) explorer_resize_dispose.val = Some(install_explorer_resize_handle()) } @@ -537,7 +610,7 @@ fn workbench_update( ToggleExplorer => ( @cmd.custom_cmd( - _ => workbench_viewer().layout(), + _ => layout_workbench_presentation(), kind=@cmd.after_render, ), { ..model, explorer_visible: !model.explorer_visible }, @@ -550,12 +623,19 @@ fn workbench_update( viewer_cmd(() => { let viewer = workbench_viewer() viewer.update_options(viewer.get_options().with_theme(theme)) + match markdown_viewer_cell.val { + Some(markdown) => + markdown.update_options( + markdown.get_options().with_theme(theme), + ) + None => () + } }), ]), { ..model, theme, }, ) } - WindowResized => (viewer_cmd(() => workbench_viewer().layout()), model) + WindowResized => (viewer_cmd(layout_workbench_presentation), model) OpenRepoPicker => open_repo_picker_update(emit, model) CloseRepoPicker => (@rabbita.none, { ..model, repo_picker: None }) BrowseRepoDirectory(path) => browse_repo_directory_update(emit, model, path) @@ -592,16 +672,73 @@ fn open_definition_location_update( } ///| -/// Installs a model, wrapping `set_model` with the Monaco host pattern for -/// scroll continuity: `set_model` always resets the viewport, so a reload -/// saves and restores the view state around it. +fn workbench_markdown_resource_kind( + text_model : @model.TextModel, +) -> @viewer.MarkdownResourceKind? { + let path = text_model.uri.path.to_lower() + if path.has_suffix(".mbt.md") { + Some(MoonBitMarkdown) + } else if path.has_suffix(".md") || text_model.get_language_id() == "markdown" { + Some(OrdinaryMarkdown) + } else { + None + } +} + +///| +/// Installs a model into the host-selected code or Markdown surface. Neither +/// reusable widget infers presentation from a URI. fn set_workbench_model( text_model : @model.TextModel, view_state : DocumentViewState, ) -> Unit { - let viewer = workbench_viewer() workbench_services().remote_language.note_document(text_model) sync_document_feedback(text_model) + match workbench_markdown_resource_kind(text_model) { + Some(resource_kind) => + set_workbench_markdown_model(text_model, resource_kind, view_state) + None => set_workbench_code_model(text_model, view_state) + } +} + +///| +fn set_workbench_markdown_model( + text_model : @model.TextModel, + resource_kind : @viewer.MarkdownResourceKind, + view_state : DocumentViewState, +) -> Unit { + let viewer = workbench_markdown_viewer() + let state = match view_state { + PreserveScroll => viewer.save_view_state() + ResetScroll | RevealDefinition(_) => None + } + workbench_viewer().set_model(None) + viewer.set_model(Some(MarkdownViewerModel(text_model, resource_kind))) + |> ignore + show_workbench_presentation("markdown") + viewer.restore_view_state(state) + viewer.handle_initialized() + match view_state { + RevealDefinition(location) => + if location.uri.to_string() == text_model.uri.to_string() { + viewer.reveal_range_in_center(text_model.validate_range(location.range)) + viewer.focus() + } + PreserveScroll | ResetScroll => () + } + dispatch_workbench_msg(ViewerEvent(DidRenderMarkdown(text_model))) +} + +///| +fn set_workbench_code_model( + text_model : @model.TextModel, + view_state : DocumentViewState, +) -> Unit { + let viewer = workbench_viewer() + match markdown_viewer_cell.val { + Some(markdown) => markdown.set_model(None) |> ignore + None => () + } match view_state { PreserveScroll => { let state = viewer.save_view_state() @@ -611,6 +748,7 @@ fn set_workbench_model( ResetScroll => viewer.set_model(Some(text_model)) RevealDefinition(_) => viewer.set_model(Some(text_model)) } + show_workbench_presentation("code") // The reference workbench presents `.mbt` sources as a top-level outline. // Keep this a host policy instead of changing the reusable Viewer's default: // other registered languages and external embedders retain expanded models. @@ -720,7 +858,7 @@ fn active_document_error_update( emit_event("language:error", error.to_json().stringify()) let clear = viewer_cmd(() => { workbench_services().remote_language.invalidate() - workbench_viewer().set_model(None) + clear_workbench_model() }) if error.code == FileNotFound { ( @@ -757,7 +895,7 @@ fn viewer_event_update( DidChangeMarkers(uris) => { // The marker service reports per-resource changes; the status line only // tracks the open document. - match workbench_viewer().get_model() { + match workbench_active_model() { Some(text_model) => if uris.search_by(uri => uri.to_string() == text_model.uri.to_string()) is Some(_) { @@ -814,6 +952,32 @@ fn viewer_event_update( (@rabbita.none, next) } } + DidRenderMarkdown(text_model) => { + let diagnostics = workbench_services().markers.diagnostics_for_resource( + text_model.uri, + ) + let line_count = text_model.get_line_count() + set_current_model(text_model_harness_json(text_model)) + emit_render_event( + line_count, + 0, + diagnostics.length(), + text_model.revision, + 0.0, + 0.0, + ) + emit_dom_mounted(line_count, diagnostics.length(), "ready", patch_ms=None) + let next = { + ..model, + status: "ready", + status_message: "", + document: Some(model_to_view(text_model)), + line_count, + diagnostics, + viewer_has_frame: true, + } + (workspace_file_tree().set_active(text_model.uri), next) + } DidResolveHover(event) => { emit_hover_status(event.ok, event.revision) (@rabbita.none, model) diff --git a/editor/internal/shell/workbench/repo_picker.mbt b/editor/internal/shell/workbench/repo_picker.mbt index 17e2e96bd..ec6e0dff3 100644 --- a/editor/internal/shell/workbench/repo_picker.mbt +++ b/editor/internal/shell/workbench/repo_picker.mbt @@ -182,7 +182,7 @@ fn workspace_root_switched_update( viewer_cmd(() => { workbench_services().remote_language.invalidate() workbench_services().markers.change_all("moon", []) - workbench_viewer().set_model(None) + clear_workbench_model() }), workspace_file_tree().refresh(), auto_open_first_file_cmd(emit), diff --git a/editor/internal/viewer/browser/controller/pkg.generated.mbti b/editor/internal/viewer/browser/controller/pkg.generated.mbti index 9b7fad092..e3f3f95d2 100644 --- a/editor/internal/viewer/browser/controller/pkg.generated.mbti +++ b/editor/internal/viewer/browser/controller/pkg.generated.mbti @@ -38,6 +38,7 @@ pub(all) struct MouseDispatchData { type MouseHandler pub fn MouseHandler::MouseHandler(PointerHandlerHelper) -> Self pub fn MouseHandler::cancel_selection(Self) -> Unit +pub fn MouseHandler::delegate_vertical_scrollbar_pointer_down(Self, @dom.Event) -> Unit pub fn MouseHandler::dispose(Self) -> Unit pub fn MouseHandler::handle_editor_wheel(Self, @dom.Event, @dom.WheelEvent) -> Bool pub fn MouseHandler::hook_browser_desperate_reveal(Self) -> Unit diff --git a/editor/internal/viewer/browser/controller/scrollbar_input.mbt b/editor/internal/viewer/browser/controller/scrollbar_input.mbt index d2101405d..80f9bfa74 100644 --- a/editor/internal/viewer/browser/controller/scrollbar_input.mbt +++ b/editor/internal/viewer/browser/controller/scrollbar_input.mbt @@ -97,6 +97,44 @@ pub fn MouseHandler::hook_scrollable_scrollbar_input( }) } +///| +/// Delegates a pointer gesture from an external vertical rail to the editor's +/// retained scrollbar, matching Monaco's +/// `AbstractScrollbar.delegatePointerDown`. The event target remains the +/// external rail so pointer capture and dragging continue outside its bounds. +pub fn MouseHandler::delegate_vertical_scrollbar_pointer_down( + self : MouseHandler, + event : @rdom.Event, +) -> Unit { + if self.disposed { + return + } + guard event.to_mouse_event() is Some(mouse) else { return } + guard mouse.get_button() == 0 else { return } + let scrollable = self.view_helper.editor_scrollable + let track_rect = scrollable.track(true).get_bounding_client_rect() + let state = scrollable.state(true) + let pointer_position = mouse.get_client_y().to_double() + let slider_start = track_rect.get_top() + state.slider_position() + let slider_end = slider_start + state.slider_size() + event.prevent_default() + event.stop_propagation() + if pointer_position < slider_start || pointer_position > slider_end { + self.scrollable_track_jump( + scrollable, + EditorScrollableElement, + vertical=true, + mouse, + ) + } + self.start_scrollable_drag( + scrollable, + EditorScrollableElement, + vertical=true, + mouse, + ) +} + ///| /// The editor's `ScrollableElement._onMouseWheel`: normalize into wheel /// units, classify the device, run the shared delta pipeline, target the diff --git a/editor/internal/viewer/browser/markdown_document/README.mbt.md b/editor/internal/viewer/browser/markdown_document/README.mbt.md index 2db48067a..82ca785fd 100644 --- a/editor/internal/viewer/browser/markdown_document/README.mbt.md +++ b/editor/internal/viewer/browser/markdown_document/README.mbt.md @@ -23,7 +23,7 @@ The package owns no model, provider, marker store, or request policy. Root and the hover browser package own those higher-level contracts. ```mbt nocheck -// The root installs this as the Markdown BrowserPresentation. +// MarkdownViewer installs this as its rich-document presentation. let document_view = MarkdownDocumentView::new(host, model_source) document_view.set_source(projection) // replaces the article, keeps the root document_view.dispose() diff --git a/editor/internal/viewer/browser/unified_diff/moon.pkg b/editor/internal/viewer/browser/unified_diff/moon.pkg deleted file mode 100644 index 5d723dc94..000000000 --- a/editor/internal/viewer/browser/unified_diff/moon.pkg +++ /dev/null @@ -1,6 +0,0 @@ -import { - "moonbit-community/rabbita/dom" @rdom, - "moonbitlang/editor/viewer/common/unified_diff" @unified_diff_model, -} - -supported_targets = "js" diff --git a/editor/internal/viewer/browser/unified_diff/pkg.generated.mbti b/editor/internal/viewer/browser/unified_diff/pkg.generated.mbti deleted file mode 100644 index b198256a2..000000000 --- a/editor/internal/viewer/browser/unified_diff/pkg.generated.mbti +++ /dev/null @@ -1,22 +0,0 @@ -// Generated using `moon info`, DON'T EDIT IT -package "moonbitlang/editor/internal/viewer/browser/unified_diff" - -import { - "moonbit-community/rabbita/dom", -} - -// Values - -// Errors - -// Types and methods -type UnifiedDiffBrowserView -pub fn UnifiedDiffBrowserView::UnifiedDiffBrowserView(@dom.Element, on_comment? : (@moonbitlang/editor/viewer/common/unified_diff.UnifiedDiffLineComment) -> Unit) -> Self -pub fn UnifiedDiffBrowserView::clear(Self) -> Unit -pub fn UnifiedDiffBrowserView::dispose(Self) -> Unit -pub fn UnifiedDiffBrowserView::set_hunks(Self, Array[@moonbitlang/editor/viewer/common/unified_diff.UnifiedDiffHunk]) -> Unit -pub fn UnifiedDiffBrowserView::set_message(Self, String) -> Unit - -// Type aliases - -// Traits diff --git a/editor/internal/viewer/browser/unified_diff/unified_diff_view.mbt b/editor/internal/viewer/browser/unified_diff/unified_diff_view.mbt deleted file mode 100644 index 218909a89..000000000 --- a/editor/internal/viewer/browser/unified_diff/unified_diff_view.mbt +++ /dev/null @@ -1,354 +0,0 @@ -///| -/// Browser-owned DOM for one unified diff. The public facade owns the input -/// contract; this value owns only the subtree installed into its dedicated -/// host element. -struct UnifiedDiffBrowserView { - host : @rdom.Element - on_comment : ((@unified_diff_model.UnifiedDiffLineComment) -> Unit)? - mut root : @rdom.Element? - mut disposed : Bool -} - -///| -/// Creates an empty renderer. The caller must reserve `host` for this view and -/// must not render its own children into it. -pub fn UnifiedDiffBrowserView::UnifiedDiffBrowserView( - host : @rdom.Element, - on_comment? : (@unified_diff_model.UnifiedDiffLineComment) -> Unit, -) -> UnifiedDiffBrowserView { - { host, on_comment, root: None, disposed: false } -} - -///| -/// Replaces the installed diff atomically so callers never expose a mixture -/// of rows from two document generations. -pub fn UnifiedDiffBrowserView::set_hunks( - self : UnifiedDiffBrowserView, - hunks : Array[@unified_diff_model.UnifiedDiffHunk], -) -> Unit { - self.install(() => render_unified_diff(hunks, self.on_comment)) -} - -///| -/// Replaces the current comparison with a renderer-owned status message. -pub fn UnifiedDiffBrowserView::set_message( - self : UnifiedDiffBrowserView, - message : String, -) -> Unit { - self.install(() => render_message(message)) -} - -///| -/// Swaps the installed subtree for a freshly rendered one. The replacement is -/// built before the previous root is detached so the host never holds rows from -/// two document generations at once. -fn UnifiedDiffBrowserView::install( - self : UnifiedDiffBrowserView, - render : () -> @rdom.Element, -) -> Unit { - if self.disposed { - return - } - let next = render() - self.detach_root() - self.host.append_child(next.as_node()) - self.root = Some(next) -} - -///| -/// Removes rendered rows while retaining the reusable host binding. -pub fn UnifiedDiffBrowserView::clear(self : UnifiedDiffBrowserView) -> Unit { - if self.disposed { - return - } - self.detach_root() -} - -///| -fn UnifiedDiffBrowserView::detach_root(self : UnifiedDiffBrowserView) -> Unit { - if self.root is Some(root) { - self.host.remove_child(root.as_node()) - self.root = None - } -} - -///| -/// Releases the renderer-owned subtree. Disposal is idempotent and leaves the -/// caller's host element in place. -pub fn UnifiedDiffBrowserView::dispose(self : UnifiedDiffBrowserView) -> Unit { - if self.disposed { - return - } - self.clear() - self.disposed = true -} - -///| -fn render_unified_diff( - hunks : Array[@unified_diff_model.UnifiedDiffHunk], - on_comment : ((@unified_diff_model.UnifiedDiffLineComment) -> Unit)?, -) -> @rdom.Element { - if hunks.is_empty() { - return render_message("No textual changes.") - } - let document = @rdom.document() - let root = diff_root(document) - for hunk_index, hunk in hunks { - let section = document.create_element("section") - section.set_attribute("class", "moonbit-unified-diff-hunk") - section.set_attribute("data-hunk-index", hunk_index.to_string()) - let header = text_element( - document, - "div", - "moonbit-unified-diff-hunk-header", - hunk_header(hunk), - ) - header.set_attribute("role", "heading") - header.set_attribute("aria-level", "2") - section.append_child(header.as_node()) - for line in hunk.lines { - section.append_child(render_line(document, line, on_comment).as_node()) - } - root.append_child(section.as_node()) - } - root -} - -///| -fn render_message(message : String) -> @rdom.Element { - let document = @rdom.document() - let root = diff_root(document) - root.append_child( - text_element(document, "div", "moonbit-unified-diff-empty", message).as_node(), - ) - root -} - -///| -fn diff_root(document : @rdom.Document) -> @rdom.Element { - let root = document.create_element("section") - root.set_attribute("class", "moonbit-unified-diff") - root.set_attribute("role", "region") - root.set_attribute("aria-label", "Unified diff") - root.set_attribute("tabindex", "0") - root -} - -///| -fn render_line( - document : @rdom.Document, - line : @unified_diff_model.UnifiedDiffLine, - on_comment : ((@unified_diff_model.UnifiedDiffLineComment) -> Unit)?, -) -> @rdom.Element { - let (kind, marker) = match line.kind { - Context => ("context", " ") - Deletion => ("deletion", "-") - Addition => ("addition", "+") - } - let row = document.create_element("div") - let row_class = "moonbit-unified-diff-line moonbit-unified-diff-line-\{kind}" - row.set_attribute("class", row_class) - row.set_attribute("data-line-kind", kind) - row.set_attribute("role", "group") - row.set_attribute("aria-label", accessible_line_label(line)) - if line.original_line_number is Some(number) { - row.set_attribute("data-original-line", number.to_string()) - } - if line.modified_line_number is Some(number) { - row.set_attribute("data-modified-line", number.to_string()) - } - let original_number = text_element( - document, - "span", - "moonbit-unified-diff-line-number", - line.original_line_number.map(number => number.to_string()).unwrap_or(""), - ) - original_number.set_attribute("aria-hidden", "true") - let modified_number = text_element( - document, - "span", - "moonbit-unified-diff-line-number", - line.modified_line_number.map(number => number.to_string()).unwrap_or(""), - ) - modified_number.set_attribute("aria-hidden", "true") - let marker_element = text_element( - document, "span", "moonbit-unified-diff-line-marker", marker, - ) - let group = match on_comment { - Some(on_comment) => { - row.set_attribute( - "class", - "\{row_class} moonbit-unified-diff-line-commentable", - ) - let group = document.create_element("div") - group.set_attribute("class", "moonbit-unified-diff-line-group") - let action = document.create_element("button") - action.set_attribute("type", "button") - action.set_attribute("class", "moonbit-unified-diff-comment-action") - action.set_attribute("title", comment_action_label(line)) - action.set_attribute("aria-label", comment_action_label(line)) - action.append_child(document.create_text_node("+").as_node()) - action.add_event_listener("click", event => { - event.prevent_default() - event.stop_propagation() - show_line_comment_editor(document, group, action, line, on_comment) - }) - row.append_child(action.as_node()) - Some(group) - } - None => None - } - row.append_child(original_number.as_node()) - row.append_child(modified_number.as_node()) - row.append_child(marker_element.as_node()) - row.append_child( - text_element(document, "code", "moonbit-unified-diff-line-text", line.text).as_node(), - ) - match group { - Some(group) => { - group.append_child(row.as_node()) - group - } - None => row - } -} - -///| -/// Lazily installs one inline editor per line. Large diffs pay for only the -/// compact comment button until the reviewer explicitly opens a draft. -fn show_line_comment_editor( - document : @rdom.Document, - group : @rdom.Element, - action : @rdom.Element, - line : @unified_diff_model.UnifiedDiffLine, - on_comment : (@unified_diff_model.UnifiedDiffLineComment) -> Unit, -) -> Unit { - if group.query_selector(".moonbit-unified-diff-comment-form") is Some(form) { - if form.query_selector("textarea") is Some(textarea) { - unified_diff_focus(textarea) - } - return - } - let form = document.create_element("div") - form.set_attribute("class", "moonbit-unified-diff-comment-form") - form.set_attribute("role", "group") - form.set_attribute("aria-label", comment_action_label(line)) - let textarea = document.create_element("textarea") - textarea.set_attribute("rows", "2") - textarea.set_attribute("placeholder", "Add review comment") - textarea.set_attribute("aria-label", "Review comment") - let actions = document.create_element("div") - actions.set_attribute("class", "moonbit-unified-diff-comment-actions") - let cancel = document.create_element("button") - cancel.set_attribute("type", "button") - cancel.set_attribute("class", "moonbit-unified-diff-comment-cancel") - cancel.append_child(document.create_text_node("Cancel").as_node()) - let submit = document.create_element("button") - submit.set_attribute("type", "button") - submit.set_attribute("class", "moonbit-unified-diff-comment-submit") - submit.set_attribute("disabled", "true") - submit.append_child(document.create_text_node("Add comment").as_node()) - let dismiss = () => { - if group.query_selector(".moonbit-unified-diff-comment-form") - is Some(active) { - group.remove_child(active.as_node()) - } - unified_diff_focus(action) - } - let commit = () => { - let comment = unified_diff_textarea_value(textarea).trim().to_owned() - if comment.is_empty() { - return - } - dismiss() - on_comment({ line, comment }) - } - cancel.add_event_listener("click", event => { - event.prevent_default() - dismiss() - }) - submit.add_event_listener("click", event => { - event.prevent_default() - commit() - }) - textarea.add_event_listener("input", _ => { - if unified_diff_textarea_value(textarea).is_blank() { - submit.set_attribute("disabled", "true") - } else { - submit.remove_attribute("disabled") - } - }) - textarea.add_event_listener("keydown", event => { - guard event.to_keyboard_event() is Some(keyboard) else { return } - match keyboard.key() { - "Escape" => { - event.prevent_default() - dismiss() - } - "Enter" if keyboard.ctrl_key() || keyboard.meta_key() => { - event.prevent_default() - commit() - } - _ => () - } - }) - actions.append_child(cancel.as_node()) - actions.append_child(submit.as_node()) - form.append_child(textarea.as_node()) - form.append_child(actions.as_node()) - group.append_child(form.as_node()) - unified_diff_focus(textarea) -} - -///| -fn comment_action_label(line : @unified_diff_model.UnifiedDiffLine) -> String { - match (line.kind, line.original_line_number, line.modified_line_number) { - (Deletion, Some(number), _) => "Comment on original line \{number}" - (Addition | Context, _, Some(number)) => - "Comment on modified line \{number}" - _ => "Comment on diff line" - } -} - -///| -/// The visual number columns stay hidden from assistive technology so they are -/// not announced as detached digits. One row label exposes the semantic change -/// kind, its applicable line numbers, and the source text in reading order. -fn accessible_line_label(line : @unified_diff_model.UnifiedDiffLine) -> String { - let kind = match line.kind { - Context => "Unchanged" - Deletion => "Deletion" - Addition => "Addition" - } - let original = line.original_line_number - .map(number => ", original line \{number}") - .unwrap_or("") - let modified = line.modified_line_number - .map(number => ", modified line \{number}") - .unwrap_or("") - "\{kind}\{original}\{modified}: \{line.text}" -} - -///| -fn text_element( - document : @rdom.Document, - tag : String, - class_name : String, - text : String, -) -> @rdom.Element { - let element = document.create_element(tag) - element.set_attribute("class", class_name) - element.append_child(document.create_text_node(text).as_node()) - element -} - -///| -fn hunk_header(hunk : @unified_diff_model.UnifiedDiffHunk) -> String { - "@@ -\{hunk.original_start_line_number},\{hunk.original_line_count} +\{hunk.modified_start_line_number},\{hunk.modified_line_count} @@" -} - -///| -extern "js" fn unified_diff_textarea_value(element : @rdom.Element) -> String = "(element) => element.value ?? ''" - -///| -extern "js" fn unified_diff_focus(element : @rdom.Element) -> Unit = "(element) => element.focus({ preventScroll: true })" diff --git a/editor/internal/viewer/browser/view/geometry_view_lines_wbtest.mbt b/editor/internal/viewer/browser/view/geometry_view_lines_wbtest.mbt index 6eef118c2..59a7cd130 100644 --- a/editor/internal/viewer/browser/view/geometry_view_lines_wbtest.mbt +++ b/editor/internal/viewer/browser/view/geometry_view_lines_wbtest.mbt @@ -8,7 +8,7 @@ fn geometry_view_lines( visible_range : @base_common.Range, layout : @view_layout.ViewLayout, fixture : ViewLineFixture, - scheduler? : RunOnceScheduler = RunOnceScheduler(200), + scheduler? : @base_browser.RunOnceScheduler = RunOnceScheduler(200), lines_content? : @rdom.Element = view_line_fixture_context(fixture), ) -> ViewLines { let configuration = ViewLinesConfiguration::ViewLinesConfiguration() @@ -498,9 +498,10 @@ test "ViewLines width sweep preserves one ceil monotonic and full-document reset ///| test "ViewLines scheduler debounces fires the latest callback and disposes pending work" { let spy = GeometryTimerSpy::new() - let scheduler = RunOnceScheduler::new_with_timer(200, (callback, delay_ms) => { - spy.schedule_timeout(callback, delay_ms) - }) + let scheduler = @base_browser.RunOnceScheduler::new_with_timer(200, ( + callback, + delay_ms, + ) => spy.schedule_timeout(callback, delay_ms)) let fired : Array[Int] = [] scheduler.schedule(() => fired.push(1)) guard scheduler.handle is Some(_) else { @@ -533,9 +534,10 @@ test "ViewLines scheduler debounces fires the latest callback and disposes pendi ///| test "ViewLines 200ms scheduler guards and cancel-before-slow ordering" { let spy = GeometryTimerSpy::new() - let scheduler = RunOnceScheduler::new_with_timer(200, (callback, delay_ms) => { - spy.schedule_timeout(callback, delay_ms) - }) + let scheduler = @base_browser.RunOnceScheduler::new_with_timer(200, ( + callback, + delay_ms, + ) => spy.schedule_timeout(callback, delay_ms)) let fixture = new_view_line_fixture(12.0, 10.0) let input = geometry_line_input("a") let line = mapped_fixture_line(input, fixture, 2) diff --git a/editor/internal/viewer/browser/view/pkg.generated.mbti b/editor/internal/viewer/browser/view/pkg.generated.mbti index ff3739025..d838b6784 100644 --- a/editor/internal/viewer/browser/view/pkg.generated.mbti +++ b/editor/internal/viewer/browser/view/pkg.generated.mbti @@ -110,6 +110,7 @@ pub fn View::measure_line_selection(Self, Int, Int, Int, keep_empty? : Bool) -> pub fn View::offset_for_column(Self, Int, Int) -> Double pub fn View::render(Self, ViewContext, ViewRenderInput) -> Unit pub fn View::should_suppress_mouse_down_on_view_zone(Self, String) -> Bool +pub fn View::view_zone_layout_infos(Self) -> Array[(String, Int, Double)] pub fn View::visible_range_for_position(Self, Int, Int) -> HorizontalPosition? type ViewConfigurationChangedEvent diff --git a/editor/internal/viewer/browser/view/view.mbt b/editor/internal/viewer/browser/view/view.mbt index d10d1b42d..b853a9917 100644 --- a/editor/internal/viewer/browser/view/view.mbt +++ b/editor/internal/viewer/browser/view/view.mbt @@ -173,6 +173,16 @@ pub fn View::change_view_zones( self.view_zones.change_view_zones(update) |> ignore } +///| +/// Read-only geometry seam for owners that coordinate multiple ordinary +/// viewers. Each tuple is `(zone id, model after-line, actual pixel height)`; +/// mutable ViewZone registration and ViewLayout remain encapsulated here. +pub fn View::view_zone_layout_infos( + self : View, +) -> Array[(String, Int, Double)] { + self.view_zones.layout_infos() +} + ///| /// Live mouse-suppression delegation used by `PointerHandlerHelper`. pub fn View::should_suppress_mouse_down_on_view_zone( diff --git a/editor/internal/viewer/browser/view/view_lines.mbt b/editor/internal/viewer/browser/view/view_lines.mbt index 201302170..a29415895 100644 --- a/editor/internal/viewer/browser/view/view_lines.mbt +++ b/editor/internal/viewer/browser/view/view_lines.mbt @@ -84,69 +84,6 @@ fn LastRenderedData::set_current_visible_range( self.current_visible_range = current_visible_range } -///| -priv struct RunOnceScheduler { - delay_ms : Int - mut handle : @base_common.Disposable? - schedule_timeout : (() -> Unit, Int) -> @base_common.Disposable -} - -///| -fn RunOnceScheduler::RunOnceScheduler(delay_ms : Int) -> RunOnceScheduler { - RunOnceScheduler::new_with_timer(delay_ms, @base_browser.schedule_timeout) -} - -///| -/// A timer-capability constructor keeps the production scheduler identical to -/// `RunOnceScheduler` while making its cancel/reschedule contract observable -/// without sleeping in white-box tests. -fn RunOnceScheduler::new_with_timer( - delay_ms : Int, - schedule_timeout : (() -> Unit, Int) -> @base_common.Disposable, -) -> RunOnceScheduler { - { delay_ms, handle: None, schedule_timeout } -} - -///| -fn RunOnceScheduler::is_scheduled(self : RunOnceScheduler) -> Bool { - self.handle is Some(_) -} - -///| -fn RunOnceScheduler::schedule( - self : RunOnceScheduler, - callback : () -> Unit, -) -> Unit { - // Source `RunOnceScheduler.schedule` always cancels the previous token and - // starts a fresh delay window. - self.cancel() - self.handle = Some( - (self.schedule_timeout)( - () => { - self.handle = None - callback() - }, - self.delay_ms, - ), - ) -} - -///| -fn RunOnceScheduler::cancel(self : RunOnceScheduler) -> Unit { - match self.handle { - Some(handle) => { - handle.dispose() - self.handle = None - } - None => () - } -} - -///| -fn RunOnceScheduler::dispose(self : RunOnceScheduler) -> Unit { - self.cancel() -} - ///| /// The public owner type retains only its caller-proven methods; its lifecycle /// and retained-render state stay private to `browser/view`. @@ -167,7 +104,7 @@ struct ViewLines { mut view_line_to_model_line : (Int) -> Int mut model_line_text_direction : (Int) -> @view_layout.TextDirection line_factory : () -> ViewLine - async_update_line_widths : RunOnceScheduler + async_update_line_widths : @base_browser.RunOnceScheduler last_rendered_data : LastRenderedData mut max_line_width : Double mut last_layout : @view_layout.ViewLayout? diff --git a/editor/internal/viewer/browser/view/view_zones.mbt b/editor/internal/viewer/browser/view/view_zones.mbt index 5a4fb8042..d93f601a8 100644 --- a/editor/internal/viewer/browser/view/view_zones.mbt +++ b/editor/internal/viewer/browser/view/view_zones.mbt @@ -388,6 +388,32 @@ fn ViewZones::zone_keys_snapshot(self : ViewZones) -> Array[String] { keys } +///| +/// Returns the live layout height together with the caller's original model +/// anchor for every registered zone. `LinesLayout` stores only the projected +/// view-line anchor, which cannot be converted back losslessly when the zone +/// follows hidden source lines. Keeping the model anchor from the delegate and +/// the actual rounded whitespace height from ViewLayout gives coordinators a +/// read-only, pixel-accurate snapshot without exposing the mutable registry. +fn ViewZones::layout_infos(self : ViewZones) -> Array[(String, Int, Double)] { + guard self.context is Some(context) else { return [] } + let layout_heights : Map[String, Double] = Map([]) + for whitespace in context.view_model.get_whitespaces() { + layout_heights.set(whitespace.id, whitespace.height.to_double()) + } + let result : Array[(String, Int, Double)] = [] + for _, zone in self.zones { + result.push( + ( + zone.whitespace_id, + zone.delegate.after_line_number, + layout_heights.get(zone.whitespace_id).unwrap_or(0.0), + ), + ) + } + result +} + ///| fn ViewZones::zone_ordinal( _self : ViewZones, @@ -589,11 +615,13 @@ fn ViewZones::layout_zone( guard self.zones.get(id) is Some(zone) else { return false } let props = self.compute_whitespace_props(zone.delegate) zone.is_in_hidden_area = props.is_in_hidden_area - // Ordinal and minimum width intentionally remain insertion-time facts. + // Ordinal remains an insertion-time ordering fact. Minimum width is live so + // viewport-rendered zone content can grow its horizontal extent lazily. whitespace_accessor.change_one_whitespace( zone.whitespace_id, props.after_view_line_number, props.height_in_px, + new_min_width=props.min_width_in_px, ) self.safe_call_on_computed_height(zone.delegate, props.height_in_px) self.should_render = true @@ -664,6 +692,22 @@ fn ViewZones::safe_call_on_dom_node_top( } } +///| +fn ViewZones::safe_call_on_dom_node_layout( + self : ViewZones, + zone : @editor_browser.ViewZone, + visible : Bool, + top : Double, +) -> Unit { + match zone.on_dom_node_layout { + Some(callback) => + callback(visible, top) catch { + _ => self.report_callback_error("onDomNodeLayout") + } + None => () + } +} + ///| fn ViewZones::report_callback_error( self : ViewZones, @@ -728,6 +772,11 @@ fn ViewZones::render( zone.delegate, visible_zone.vertical_offset - scroll_top, ) + self.safe_call_on_dom_node_layout( + zone.delegate, + true, + visible_zone.vertical_offset - scroll_top, + ) } None => { if zone.is_visible { @@ -738,6 +787,11 @@ fn ViewZones::render( zone.delegate, offscreen_absolute_top - scroll_top, ) + self.safe_call_on_dom_node_layout( + zone.delegate, + false, + offscreen_absolute_top - scroll_top, + ) } } zone.dom_node.set_top(css_pixels_double(new_top)) diff --git a/editor/internal/viewer/browser/view/view_zones_branch_matrix_wbtest.mbt b/editor/internal/viewer/browser/view/view_zones_branch_matrix_wbtest.mbt index 6055adabd..3ed519e12 100644 --- a/editor/internal/viewer/browser/view/view_zones_branch_matrix_wbtest.mbt +++ b/editor/internal/viewer/browser/view/view_zones_branch_matrix_wbtest.mbt @@ -625,6 +625,45 @@ test "ViewZones refresh source-aware visibility when merged projection is unchan fixture.dispose() } +///| +test "ViewZones layout snapshot preserves model anchors after hidden source" { + install_view_lifecycle_test_browser() + defer restore_view_lifecycle_test_browser() + let fixture = view_zones_branch_fixture( + "/view-zones-layout-info-hidden-source.txt", + ( + #|first + #|markdown-a + #|markdown-b + #|markdown-c + #|last + ), + @view_model.ViewModelOptions::no_wrap(), + 20, + ) + defer fixture.dispose() + assert_true( + fixture.view_model.set_hidden_areas([Range(2, 1, 4, 1)], source="markdown"), + ) + let zone_id = fixture.add_zone( + ViewZone( + 4, + @rdom.document().create_element("div"), + ignore_hidden_area_source="markdown", + height_in_px=19.75, + ), + ) + // The projected whitespace can only retain the nearest visible view line. + assert_eq(fixture.whitespace(zone_id).after_line_number, 1) + guard fixture.view.view_zone_layout_infos() is [info] else { + fail("expected one ViewZone layout snapshot") + } + let (id, after_model_line_number, height_in_px) = info + assert_eq(id, zone_id) + assert_eq(after_model_line_number, 4) + assert_eq(height_in_px, 19.0) +} + ///| test "ViewZones preserves raw height callbacks through retained ToInt32" { install_view_lifecycle_test_browser() @@ -690,7 +729,7 @@ test "ViewZones preserves raw height callbacks through retained ToInt32" { } ///| -test "ViewZones layoutZone rereads geometry but retains ordinal width and nodes" { +test "ViewZones layoutZone rereads geometry and width but retains ordinal and nodes" { install_view_lifecycle_test_browser() defer restore_view_lifecycle_test_browser() let fixture = view_zones_branch_fixture( @@ -759,7 +798,7 @@ test "ViewZones layoutZone rereads geometry but retains ordinal width and nodes" assert_eq(updated.after_line_number, 3) assert_eq(updated.height, 40) assert_eq(updated.ordinal, 17) - assert_eq(updated.min_width, 42) + assert_eq(updated.min_width, 999) assert_eq(original_callback_count.val, 1) assert_eq(replacement_callback_count.val, 1) assert_eq(replacement_callback_height.val, 40.0) diff --git a/editor/internal/viewer/browser/view/view_zones_geometry_wbtest.mbt b/editor/internal/viewer/browser/view/view_zones_geometry_wbtest.mbt index e0be5bede..7e274a712 100644 --- a/editor/internal/viewer/browser/view/view_zones_geometry_wbtest.mbt +++ b/editor/internal/viewer/browser/view/view_zones_geometry_wbtest.mbt @@ -101,6 +101,15 @@ fn view_zones_geometry_fixture( height_in_px=17.0, margin_dom_node=margin_zone, on_dom_node_top=_ => record_view_zones_geometry_event("callback:top"), + on_dom_node_layout=(visible, _) => { + record_view_zones_geometry_event( + if visible { + "callback:layout-visible" + } else { + "callback:layout-hidden" + }, + ) + }, ) let layout = @view_layout.LinesLayout(1, 20, 22, 0) let whitespace_id = Ref("") @@ -166,6 +175,7 @@ test "ViewZones writes callbacks and zone geometry before container widths" { render_geometry_zone(part, visible, 100.0) let events = view_zones_geometry_events() let callback = geometry_event_index(events, "callback:top") + let layout_callback = geometry_event_index(events, "callback:layout-visible") let content_top = geometry_event_index(events, "style:content-zone:top:42px") let content_height = geometry_event_index( events, "style:content-zone:height:17px", @@ -188,12 +198,13 @@ test "ViewZones writes callbacks and zone geometry before container widths" { ) for index in [ - callback, content_top, content_height, content_display, margin_top, margin_height, - margin_display, content_width, margin_width, + callback, layout_callback, content_top, content_height, content_display, margin_top, + margin_height, margin_display, content_width, margin_width, ] { assert_true(index >= 0) } assert_true(callback < content_top) + assert_true(callback < layout_callback && layout_callback < content_top) assert_true(content_top < content_height && content_height < content_display) assert_true(content_display < margin_top) assert_true(margin_top < margin_height && margin_height < margin_display) @@ -216,4 +227,5 @@ test "ViewZones some to none retains both container widths" { for event in view_zones_geometry_events() { assert_false(event.contains(":width:")) } + assert_true(view_zones_geometry_events().contains("callback:layout-hidden")) } diff --git a/editor/viewer/agent_feedback_host.mbt b/editor/internal/viewer/code_editor_widget/agent_feedback_host.mbt similarity index 91% rename from editor/viewer/agent_feedback_host.mbt rename to editor/internal/viewer/code_editor_widget/agent_feedback_host.mbt index 6003a9e7c..1a090a488 100644 --- a/editor/viewer/agent_feedback_host.mbt +++ b/editor/internal/viewer/code_editor_widget/agent_feedback_host.mbt @@ -7,7 +7,7 @@ // (`_onSelectionChanged` / `_show` / `_updatePosition`) and the glyph-click // line selection (`_selectLine`). The widget DOM lives in // `internal/viewer/contrib/agent_feedback/browser`; the DOM-free store and -// grouping live in `internal/viewer/contrib/agent_feedback`. Viewer-facing +// grouping live in `internal/viewer/contrib/agent_feedback`. CodeEditorWidget-facing // subscriptions stay in the root composition package: moving them into an // internal contribution would reverse the dependency on `viewer` and create a // package cycle. @@ -42,6 +42,7 @@ fn register_agent_feedback_contribution( ) -> Unit { registry.register_editor_contribution( "agentFeedback.editorInputContribution", + AgentFeedbackInput, Eventually, _viewer => AgentFeedbackInput(AgentFeedbackEditorInputContribution()), viewer => viewer.hook_agent_feedback(), @@ -68,7 +69,9 @@ fn register_agent_feedback_contribution( ///| /// The constructor-time wiring of `AgentFeedbackEditorInputContribution`: /// service, cursor/model, scroll, and mouse subscriptions. -fn Viewer::hook_agent_feedback(self : Viewer) -> Array[@base_common.Disposable] { +fn CodeEditorWidget::hook_agent_feedback( + self : CodeEditorWidget, +) -> Array[@base_common.Disposable] { self.sync_agent_feedback_lane() [ self.services.agent_feedback.on_did_change_feedback(event => { @@ -110,8 +113,8 @@ fn Viewer::hook_agent_feedback(self : Viewer) -> Array[@base_common.Disposable] /// Whether a service event's resource is the installed model's — /// the per-editor filter every contribution subscription applies /// (previously homed with the comments contrib). -fn Viewer::is_current_resource( - self : Viewer, +fn CodeEditorWidget::is_current_resource( + self : CodeEditorWidget, resource : @base_common.Uri, ) -> Bool { match self.get_model() { @@ -124,8 +127,8 @@ fn Viewer::is_current_resource( /// The live input-contribution instance for this editor — the /// `getContribution(AgentFeedbackEditorInputContribution.ID)` analog. /// `None` after the contribution disposed. -fn Viewer::agent_feedback_input_contribution( - self : Viewer, +fn CodeEditorWidget::agent_feedback_input_contribution( + self : CodeEditorWidget, ) -> @agent_feedback_browser.AgentFeedbackEditorInputContribution? { match self.contributions.lookup("agentFeedback.editorInputContribution") { Some(entry) => @@ -140,7 +143,9 @@ fn Viewer::agent_feedback_input_contribution( ///| /// Whether the inline input is showing — the `_visible` read the keybinding /// gates and the scroll hook consult. -fn Viewer::agent_feedback_input_visible(self : Viewer) -> Bool { +fn CodeEditorWidget::agent_feedback_input_visible( + self : CodeEditorWidget, +) -> Bool { match self.agent_feedback_input_contribution() { Some(ic) => ic.visible None => false @@ -150,7 +155,7 @@ fn Viewer::agent_feedback_input_visible(self : Viewer) -> Bool { ///| /// Whether the current model's resource has the feedback affordances on — /// the `_getSessionForFile` stand-in. -fn Viewer::agent_feedback_enabled(self : Viewer) -> Bool { +fn CodeEditorWidget::agent_feedback_enabled(self : CodeEditorWidget) -> Bool { guard self.is_code_presentation() else { return false } match self.get_model() { Some(model) => self.services.agent_feedback.is_feedback_enabled(model.uri) @@ -161,7 +166,9 @@ fn Viewer::agent_feedback_enabled(self : Viewer) -> Bool { ///| /// `_onModelChanged`: hide, clear the glyph, drop the one-shot suppress, and /// re-evaluate the lane for the new resource. -fn Viewer::on_agent_feedback_model_changed(self : Viewer) -> Unit { +fn CodeEditorWidget::on_agent_feedback_model_changed( + self : CodeEditorWidget, +) -> Unit { self.hide_agent_feedback_input(refocus_editor=false) self.clear_agent_feedback_hover_glyph() match self.agent_feedback_input_contribution() { @@ -178,7 +185,7 @@ fn Viewer::on_agent_feedback_model_changed(self : Viewer) -> Unit { /// margin while the resource offers feedback, restore the host's base width /// when it no longer does (the comments contrib's store/restore shape; the /// upstream added/subtracted a delta). -fn Viewer::sync_agent_feedback_lane(self : Viewer) -> Unit { +fn CodeEditorWidget::sync_agent_feedback_lane(self : CodeEditorWidget) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } let needed = self.agent_feedback_enabled() match (needed, ic.lane_base_width) { @@ -203,8 +210,8 @@ fn Viewer::sync_agent_feedback_lane(self : Viewer) -> Unit { ///| /// `_onEditorMouseMove`: the glyph follows the hovered line unless the input /// is open (or holds a draft). -fn Viewer::on_agent_feedback_mouse_move( - self : Viewer, +fn CodeEditorWidget::on_agent_feedback_mouse_move( + self : CodeEditorWidget, event : @editor_browser.EditorMouseEvent, ) -> Unit { if self.agent_feedback_input_visible() || self.agent_feedback_has_draft() { @@ -224,7 +231,10 @@ fn Viewer::on_agent_feedback_mouse_move( ///| /// `_updateHoverGlyph` and its gates: a valid, non-empty line in an enabled /// resource whose line has no existing feedback. -fn Viewer::update_agent_feedback_hover_glyph(self : Viewer, line : Int) -> Unit { +fn CodeEditorWidget::update_agent_feedback_hover_glyph( + self : CodeEditorWidget, + line : Int, +) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } guard self.get_model() is Some(model) else { self.clear_agent_feedback_hover_glyph() @@ -290,7 +300,9 @@ fn agent_feedback_line_has_feedback( ///| /// `_clearHoverGlyph`. -fn Viewer::clear_agent_feedback_hover_glyph(self : Viewer) -> Unit { +fn CodeEditorWidget::clear_agent_feedback_hover_glyph( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } if ic.hover_line_number == -1 { return @@ -315,8 +327,8 @@ fn Viewer::clear_agent_feedback_hover_glyph(self : Viewer) -> Unit { /// (`agentFeedbackEditorInputContribution.ts:346-348`): the hit element /// carries `agent-feedback-glyph`. Targets carry their DOM element now, so /// the upstream predicate ports verbatim. -fn Viewer::is_agent_feedback_glyph_target( - self : Viewer, +fn CodeEditorWidget::is_agent_feedback_glyph_target( + self : CodeEditorWidget, target : @editor_browser.MouseTarget, ) -> Bool { guard self.agent_feedback_input_contribution() is Some(_) else { @@ -339,8 +351,8 @@ extern "js" fn element_class_list_contains( /// the selection-change handler); anything else records the pending press /// and auto-hides. Widget-internal presses never reach here — the widget's /// containment stops propagation (`_isWidgetTarget`). -fn Viewer::on_agent_feedback_mouse_down( - self : Viewer, +fn CodeEditorWidget::on_agent_feedback_mouse_down( + self : CodeEditorWidget, event : @editor_browser.EditorMouseEvent, ) -> Unit { if self.is_agent_feedback_glyph_target(event.target) { @@ -363,8 +375,8 @@ fn Viewer::on_agent_feedback_mouse_down( ///| /// `onMouseUp`: the press ended; re-evaluate the selection (a drag-selection /// finished here opens the input). -fn Viewer::on_agent_feedback_mouse_up( - self : Viewer, +fn CodeEditorWidget::on_agent_feedback_mouse_up( + self : CodeEditorWidget, event : @editor_browser.EditorMouseEvent, ) -> Unit { match self.agent_feedback_input_contribution() { @@ -381,7 +393,10 @@ fn Viewer::on_agent_feedback_mouse_up( /// `_selectLine`: select the whole clicked line, focus the editor (the /// selection-change handler opens the input synchronously), then move focus /// into the input so typing can start immediately. -fn Viewer::select_agent_feedback_line(self : Viewer, line : Int) -> Unit { +fn CodeEditorWidget::select_agent_feedback_line( + self : CodeEditorWidget, + line : Int, +) -> Unit { if self.agent_feedback_input_visible() && self.agent_feedback_has_draft() { self.focus_agent_feedback_input() return @@ -413,7 +428,9 @@ fn Viewer::select_agent_feedback_line(self : Viewer, line : Int) -> Unit { /// `_onSelectionChanged`: the non-empty selection drives the input's /// presence; a draft freezes it; the anchor follows the selection's active /// end (below an LTR selection, above an RTL one). -fn Viewer::on_agent_feedback_selection_changed(self : Viewer) -> Unit { +fn CodeEditorWidget::on_agent_feedback_selection_changed( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } if ic.suppress_selection_change_once { ic.suppress_selection_change_once = false @@ -446,8 +463,8 @@ fn Viewer::on_agent_feedback_selection_changed(self : Viewer) -> Unit { ///| /// `_show`. -fn Viewer::show_agent_feedback_input( - self : Viewer, +fn CodeEditorWidget::show_agent_feedback_input( + self : CodeEditorWidget, range : @base_common.Range, anchor : @base_common.Position, prefer_below : Bool, @@ -472,8 +489,8 @@ fn Viewer::show_agent_feedback_input( ///| /// `_ensureWidget`: the widget is created once per viewer, on first use; /// the overlay registration re-adds it to each per-model view. -fn Viewer::ensure_agent_feedback_input( - self : Viewer, +fn CodeEditorWidget::ensure_agent_feedback_input( + self : CodeEditorWidget, ) -> @agent_feedback_browser.AgentFeedbackInputWidget { let ic = self.agent_feedback_input_contribution() let existing = match ic { @@ -507,7 +524,7 @@ fn Viewer::ensure_agent_feedback_input( } ///| -fn Viewer::agent_feedback_has_draft(self : Viewer) -> Bool { +fn CodeEditorWidget::agent_feedback_has_draft(self : CodeEditorWidget) -> Bool { match self.agent_feedback_input_contribution() { Some({ widget: Some(widget), .. }) => widget.has_text() _ => false @@ -517,8 +534,8 @@ fn Viewer::agent_feedback_has_draft(self : Viewer) -> Bool { ///| /// `_hide` (+ the `_hideAndRefocusEditor` variant the add/cancel paths use: /// the refocus suppresses the selection re-evaluation once). -fn Viewer::hide_agent_feedback_input( - self : Viewer, +fn CodeEditorWidget::hide_agent_feedback_input( + self : CodeEditorWidget, refocus_editor~ : Bool, ) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } @@ -543,7 +560,9 @@ fn Viewer::hide_agent_feedback_input( ///| /// `_autoHide`: never discard a draft. -fn Viewer::auto_hide_agent_feedback_input(self : Viewer) -> Unit { +fn CodeEditorWidget::auto_hide_agent_feedback_input( + self : CodeEditorWidget, +) -> Unit { if self.agent_feedback_has_draft() { return } @@ -551,7 +570,9 @@ fn Viewer::auto_hide_agent_feedback_input(self : Viewer) -> Unit { } ///| -fn Viewer::focus_agent_feedback_input(self : Viewer) -> Unit { +fn CodeEditorWidget::focus_agent_feedback_input( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } if ic.visible { match ic.widget { @@ -569,7 +590,10 @@ fn Viewer::focus_agent_feedback_input(self : Viewer) -> Unit { /// refocus. The submit arm marks the accepted items submitted; the host /// reacts to the service's submit event (the chat hand-off upstream drives /// here stays outside the viewer). -fn Viewer::add_agent_feedback_from_input(self : Viewer, submit~ : Bool) -> Unit { +fn CodeEditorWidget::add_agent_feedback_from_input( + self : CodeEditorWidget, + submit~ : Bool, +) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } guard ic.widget is Some(widget) else { return } let text = widget.value().trim().to_owned() @@ -608,7 +632,9 @@ fn Viewer::add_agent_feedback_from_input(self : Viewer, submit~ : Bool) -> Unit /// position (or the live selection's active end), prefer below/above by the /// selection direction, flip when out of bounds, clamp into the editor and /// right of the gutter. -fn Viewer::update_agent_feedback_input_position(self : Viewer) -> Unit { +fn CodeEditorWidget::update_agent_feedback_input_position( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_input_contribution() is Some(ic) else { return } guard ic.widget is Some(widget) && ic.visible else { return } let target : (@base_common.Position, Bool)? = match diff --git a/editor/viewer/agent_feedback_host_wbtest.mbt b/editor/internal/viewer/code_editor_widget/agent_feedback_host_wbtest.mbt similarity index 94% rename from editor/viewer/agent_feedback_host_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/agent_feedback_host_wbtest.mbt index 234eae40b..cf389ae25 100644 --- a/editor/viewer/agent_feedback_host_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/agent_feedback_host_wbtest.mbt @@ -17,16 +17,16 @@ fn test_model_uri() -> @base_common.Uri { ///| /// Whitebox read of the per-editor input-contribution instance. -fn Viewer::test_feedback_input_contribution( - self : Viewer, +fn CodeEditorWidget::test_feedback_input_contribution( + self : CodeEditorWidget, ) -> @agent_feedback_browser.AgentFeedbackEditorInputContribution { self.agent_feedback_input_contribution().unwrap() } ///| /// Whitebox read of the per-editor widget-contribution instance. -fn Viewer::test_feedback_widget_contribution( - self : Viewer, +fn CodeEditorWidget::test_feedback_widget_contribution( + self : CodeEditorWidget, ) -> @agent_feedback_browser.AgentFeedbackEditorWidgetContribution { self.agent_feedback_widget_contribution().unwrap() } @@ -55,12 +55,12 @@ fn feedback_test_model(path : String, text : String) -> @model.TextModel { ///| fn with_feedback_test_viewer( text : String, - body : (Viewer, @agent_feedback.AgentFeedbackService) -> Unit raise, + body : (CodeEditorWidget, @agent_feedback.AgentFeedbackService) -> Unit raise, ) -> Unit raise { let backings = RootTestServiceBackings::new() - let viewer = Viewer::Viewer( + let viewer = CodeEditorWidget::CodeEditorWidget( services=backings.services, - options=ViewerOptions(font_size=12, line_height=18), + options=CodeEditorOptions(font_size=12, line_height=18), ) viewer.set_model(Some(test_model(text))) body(viewer, backings.agent_feedback) @@ -185,7 +185,7 @@ test "feedback content flush preserves central owners and refreshes model state" let backings = RootTestServiceBackings::new() let service = backings.agent_feedback let handle = service.agent_feedback_handle() - let viewer = Viewer::Viewer(services=backings.services) + let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services) let model = feedback_test_model( "/content-flush", ( @@ -249,11 +249,11 @@ test "feedback content flush preserves central owners and refreshes model state" } ///| -test "feedback model swap removal and reattach preserve only Viewer-lifetime state" { +test "feedback model swap removal and reattach preserve only CodeEditorWidget-lifetime state" { let backings = RootTestServiceBackings::new() let service = backings.agent_feedback let handle = service.agent_feedback_handle() - let viewer = Viewer::Viewer(services=backings.services) + let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services) let first = feedback_test_model( "/first", ( @@ -306,7 +306,7 @@ test "feedback model swap removal and reattach preserve only Viewer-lifetime sta assert_eq(input.hover_decoration_ids.length(), 0) assert_eq(widgets.highlight_decoration_ids.length(), 0) assert_eq(widgets.widgets.length(), 0) - // A headless Viewer has no DOM View to rebuild. The contribution keeps the + // A headless CodeEditorWidget has no DOM View to rebuild. The contribution keeps the // reply-draft store and id pool until a later mounted rebuild can apply its // normal orphan-pruning policy. assert_eq(widgets.reply_drafts[comment.id], "first model draft") @@ -344,7 +344,7 @@ test "feedback disposal drains entries tears down model state and silences servi let backings = RootTestServiceBackings::new() let service = backings.agent_feedback let handle = service.agent_feedback_handle() - let viewer = Viewer::Viewer(services=backings.services) + let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services) let model = feedback_test_model( "/dispose", ( diff --git a/editor/viewer/agent_feedback_widgets_host.mbt b/editor/internal/viewer/code_editor_widget/agent_feedback_widgets_host.mbt similarity index 91% rename from editor/viewer/agent_feedback_widgets_host.mbt rename to editor/internal/viewer/code_editor_widget/agent_feedback_widgets_host.mbt index c16837c05..a5215b210 100644 --- a/editor/viewer/agent_feedback_widgets_host.mbt +++ b/editor/internal/viewer/code_editor_widget/agent_feedback_widgets_host.mbt @@ -8,7 +8,7 @@ // (`_handleNavigation`), the item-hover range highlight (the widget's // `_highlightRange`, homed here because it touches the model), and the // reply-draft capture/prune across rebuilds. The widget DOM lives in -// `internal/viewer/contrib/agent_feedback/browser`. Viewer-facing lifecycle +// `internal/viewer/contrib/agent_feedback/browser`. CodeEditorWidget-facing lifecycle // glue stays in the root composition package because the internal contribution // is a dependency of `viewer`; importing the facade back from that package // would create a cycle. Deviations: the highlight skips the overview-ruler @@ -25,6 +25,7 @@ fn register_agent_feedback_widgets_contribution( ) -> Unit { registry.register_editor_contribution( "agentFeedback.editorWidgetContribution", + AgentFeedbackWidget, Eventually, _viewer => AgentFeedbackWidget(AgentFeedbackEditorWidgetContribution()), viewer => viewer.hook_agent_feedback_widgets(), @@ -35,8 +36,8 @@ fn register_agent_feedback_widgets_contribution( /// The live widget-contribution instance for this editor — the /// `getContribution(AgentFeedbackEditorWidgetContribution.ID)` analog. /// `None` after the contribution disposed. -fn Viewer::agent_feedback_widget_contribution( - self : Viewer, +fn CodeEditorWidget::agent_feedback_widget_contribution( + self : CodeEditorWidget, ) -> @agent_feedback_browser.AgentFeedbackEditorWidgetContribution? { match self.contributions.lookup("agentFeedback.editorWidgetContribution") { Some(entry) => @@ -52,8 +53,8 @@ fn Viewer::agent_feedback_widget_contribution( /// The constructor-time wiring of `AgentFeedbackEditorWidgetContribution`: /// rebuild on feedback/model changes, expand/reveal on navigation changes, /// relayout on scroll. -fn Viewer::hook_agent_feedback_widgets( - self : Viewer, +fn CodeEditorWidget::hook_agent_feedback_widgets( + self : CodeEditorWidget, ) -> Array[@base_common.Disposable] { self.rebuild_agent_feedback_widgets() [ @@ -81,7 +82,9 @@ fn Viewer::hook_agent_feedback_widgets( /// `_clearWidgets`: snapshot which reply composer holds focus (its blur /// fires during teardown and would clear the state), then dispose every /// widget. Returns the snapshot for the rebuild. -fn Viewer::clear_agent_feedback_widgets(self : Viewer) -> String? { +fn CodeEditorWidget::clear_agent_feedback_widgets( + self : CodeEditorWidget, +) -> String? { guard self.agent_feedback_widget_contribution() is Some(wc) else { return None } @@ -93,8 +96,8 @@ fn Viewer::clear_agent_feedback_widgets(self : Viewer) -> String? { /// payload explicit prevents lifecycle teardown from recovering state by id; /// ordinary rebuild/model paths continue through the typed central accessor /// above. -fn Viewer::clear_agent_feedback_widgets_from( - self : Viewer, +fn CodeEditorWidget::clear_agent_feedback_widgets_from( + self : CodeEditorWidget, wc : @agent_feedback_browser.AgentFeedbackEditorWidgetContribution, ) -> String? { let mut focused_reply : String? = None @@ -118,7 +121,9 @@ fn Viewer::clear_agent_feedback_widgets_from( /// group, in reverse file order so widgets further up the file render on /// top. Widget DOM is only built while a real view is attached (the /// comments contrib's `sync_comments` gate). -fn Viewer::rebuild_agent_feedback_widgets(self : Viewer) -> Unit { +fn CodeEditorWidget::rebuild_agent_feedback_widgets( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_widget_contribution() is Some(wc) else { return } let focused_reply = self.clear_agent_feedback_widgets() guard self.current_code_view() is Some(_) && self.get_model() is Some(model) else { @@ -153,8 +158,8 @@ fn Viewer::rebuild_agent_feedback_widgets(self : Viewer) -> Unit { ///| /// The widget-facing seam, closed over this viewer: service mutations plus /// navigation/highlight/layout callbacks. -fn Viewer::agent_feedback_widget_host( - self : Viewer, +fn CodeEditorWidget::agent_feedback_widget_host( + self : CodeEditorWidget, ) -> @agent_feedback_browser.AgentFeedbackEditorWidgetHost { let service = self.services.agent_feedback { @@ -209,8 +214,8 @@ fn Viewer::agent_feedback_widget_host( /// `AgentFeedbackEditorWidget.layout`: align the header center with the /// anchor line's center, clamp into the scrollable content, and pin to the /// right edge of the content area (two scrollbar widths in from it). -fn Viewer::layout_agent_feedback_widget( - self : Viewer, +fn CodeEditorWidget::layout_agent_feedback_widget( + self : CodeEditorWidget, widget : @agent_feedback_browser.AgentFeedbackEditorWidget, start_line : Int, ) -> Unit { @@ -247,7 +252,9 @@ fn Viewer::layout_agent_feedback_widget( } ///| -fn Viewer::relayout_agent_feedback_widgets(self : Viewer) -> Unit { +fn CodeEditorWidget::relayout_agent_feedback_widgets( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_widget_contribution() is Some(wc) else { return } for widget in wc.widgets { self.layout_agent_feedback_widget(widget, widget.anchor_line()) @@ -257,7 +264,9 @@ fn Viewer::relayout_agent_feedback_widgets(self : Viewer) -> Unit { ///| /// `_handleNavigation`: expand the widget containing the anchored comment, /// collapse the rest, focus the item, and reveal its range. -fn Viewer::handle_agent_feedback_navigation(self : Viewer) -> Unit { +fn CodeEditorWidget::handle_agent_feedback_navigation( + self : CodeEditorWidget, +) -> Unit { guard self.current_code_view() is Some(_) && self.get_model() is Some(model) else { return } @@ -288,8 +297,8 @@ fn Viewer::handle_agent_feedback_navigation(self : Viewer) -> Unit { /// The widget's `_highlightRange`: a whole-line `rangeHighlight` over the /// comment's lines plus the modified-style gutter bar (the overview-ruler /// companion decoration is skipped — no overview ruler). -fn Viewer::highlight_agent_feedback_range( - self : Viewer, +fn CodeEditorWidget::highlight_agent_feedback_range( + self : CodeEditorWidget, comment : @agent_feedback.SessionEditorComment, ) -> Unit { guard self.get_model() is Some(model) else { return } @@ -316,7 +325,9 @@ fn Viewer::highlight_agent_feedback_range( } ///| -fn Viewer::clear_agent_feedback_highlight(self : Viewer) -> Unit { +fn CodeEditorWidget::clear_agent_feedback_highlight( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_widget_contribution() is Some(wc) else { return } self.clear_agent_feedback_highlight_from(wc) } @@ -325,8 +336,8 @@ fn Viewer::clear_agent_feedback_highlight(self : Viewer) -> Unit { /// Direct-instance form used while the central contribution entry is /// disposing. No id-based lookup is needed once the concrete payload is in /// hand. -fn Viewer::clear_agent_feedback_highlight_from( - self : Viewer, +fn CodeEditorWidget::clear_agent_feedback_highlight_from( + self : CodeEditorWidget, wc : @agent_feedback_browser.AgentFeedbackEditorWidgetContribution, ) -> Unit { if wc.highlight_decoration_ids.is_empty() { @@ -345,7 +356,9 @@ fn Viewer::clear_agent_feedback_highlight_from( ///| /// `_pruneOrphanedReplyDrafts`: drop drafts for comments no widget renders. -fn Viewer::prune_agent_feedback_reply_drafts(self : Viewer) -> Unit { +fn CodeEditorWidget::prune_agent_feedback_reply_drafts( + self : CodeEditorWidget, +) -> Unit { guard self.agent_feedback_widget_contribution() is Some(wc) else { return } if wc.reply_drafts.is_empty() { return diff --git a/editor/viewer/async_model_features_wbtest.mbt b/editor/internal/viewer/code_editor_widget/async_model_features_wbtest.mbt similarity index 96% rename from editor/viewer/async_model_features_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/async_model_features_wbtest.mbt index 4a9e35b9b..e40cafa95 100644 --- a/editor/viewer/async_model_features_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/async_model_features_wbtest.mbt @@ -114,7 +114,7 @@ fn ManualHoverScheduler::active_count(self : ManualHoverScheduler) -> Int { } ///| -fn arm_manual_hover(viewer : Viewer) -> Unit { +fn arm_manual_hover(viewer : CodeEditorWidget) -> Unit { let hc = viewer.content_hover_controller().unwrap() let (effect, hover) = hc.hover.start_showing_or_update_hover( async_hover_anchor(), @@ -143,7 +143,7 @@ fn assert_hover_operation_state( /// Isolated concrete language/log backings narrowed into the root capability /// bundle. The bundle still owns its omitted marker and feedback defaults. struct AsyncModelFeatureTestServices { - services : ViewerServices + services : CodeEditorServices languages : @languages.Languages log_service : @log.LogService } @@ -152,7 +152,7 @@ struct AsyncModelFeatureTestServices { fn AsyncModelFeatureTestServices::new() -> AsyncModelFeatureTestServices { let log_service = @log.LogService() let languages = @languages.Languages() - let services = ViewerServices( + let services = CodeEditorServices( languages=languages.language_handle(log_service.log_handle()), log_service=log_service.log_handle(), ) @@ -163,7 +163,7 @@ fn AsyncModelFeatureTestServices::new() -> AsyncModelFeatureTestServices { fn AsyncModelFeatureTestServices::dispose( self : AsyncModelFeatureTestServices, ) -> Unit { - // The concrete backings remain caller-owned and usable after Viewer + // The concrete backings remain caller-owned and usable after CodeEditorWidget // teardown; only the omitted defaults inside the bundle need disposal. self.languages .language_handle(self.log_service.log_handle()) @@ -176,7 +176,7 @@ fn AsyncModelFeatureTestServices::dispose( ///| test "live hover timers follow 150 300 900ms and cancel stale generations" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) let model = async_test_model("/hover-timers.mbt", "hover") let scheduler = ManualHoverScheduler::new() viewer.set_hover_host_for_test( @@ -218,7 +218,7 @@ test "live hover timers follow 150 300 900ms and cancel stale generations" { ///| test "content and model invalidation clear a pending mouse-react handle" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) let first = async_test_model("/hover-react-first.mbt", "hover") let replacement = async_test_model("/hover-react-second.mbt", "hover") let scheduler = ManualHoverScheduler::new() @@ -265,7 +265,7 @@ test "content and model invalidation clear a pending mouse-react handle" { ///| test "hover widget mouseleave hides when the editor DOM is missing" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) viewer.set_model(Some(async_test_model("/hover-no-dom.mbt", "hover"))) let hc = viewer.content_hover_controller().unwrap() hc.hover = shown_hover_controller() @@ -284,7 +284,7 @@ test "hover widget mouseleave hides when the editor DOM is missing" { ///| test "programmatic hover forwards immediate mode source focus and mouse state" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) viewer.set_model(Some(async_test_model("/programmatic-hover.mbt", "hover"))) viewer.set_hover_host_for_test( (_callback, _delay_ms) => @base_common.Disposable::from(() => ()), @@ -377,7 +377,7 @@ fn hover_scroll_change( ///| test "hover scroll handling covers neither top left both and ignored input" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) viewer.set_model(Some(async_test_model("/hover-scroll.mbt", "hover"))) let hc = viewer.content_hover_controller().unwrap() @@ -441,7 +441,7 @@ fn shown_hover_controller() -> @hover.HoverController { ///| test "invisible hover dismissal cancels work idempotently" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) viewer.set_model( Some(async_test_model("/hover-invisible-dismiss.mbt", "hover")), ) @@ -474,7 +474,7 @@ test "invisible hover dismissal cancels work idempotently" { ///| test "visible hover dismissal clears the shown view" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) viewer.set_model( Some(async_test_model("/hover-visible-dismiss.mbt", "hover")), ) @@ -493,7 +493,7 @@ test "visible hover dismissal clears the shown view" { ///| test "content invalidation cancels hover request but preserves shown view" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) let model = async_test_model("/hover-content.mbt", "content") viewer.set_model(Some(model)) let hc = viewer.content_hover_controller().unwrap() @@ -517,7 +517,7 @@ test "content invalidation cancels hover request but preserves shown view" { ///| test "content invalidation removes loading visibility and permits same-anchor restart" { let backings = AsyncModelFeatureTestServices::new() - let viewer = Viewer(services=backings.services) + let viewer = CodeEditorWidget(services=backings.services) let model = async_test_model("/hover-loading-content.mbt", "content") viewer.set_model(Some(model)) let hc = viewer.content_hover_controller().unwrap() diff --git a/editor/viewer/attach_model.mbt b/editor/internal/viewer/code_editor_widget/attach_model.mbt similarity index 85% rename from editor/viewer/attach_model.mbt rename to editor/internal/viewer/code_editor_widget/attach_model.mbt index ec6bbf46e..8826c267c 100644 --- a/editor/viewer/attach_model.mbt +++ b/editor/internal/viewer/code_editor_widget/attach_model.mbt @@ -22,13 +22,11 @@ /// /// ViewModel callbacks use the generation gate, while model listeners are /// disposed by `ModelData` teardown before a replacement can observe them. -fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { +fn CodeEditorWidget::attach_model( + self : CodeEditorWidget, + model : @model.TextModel, +) -> Unit { let model_data_generation = self.model_slot.begin_attach_generation() - let presentation_kind = if self.force_code_presentation { - CodeDocument - } else { - document_presentation_kind(model) - } let listeners_to_remove : Array[@base_common.Disposable] = [] // `data-mode-id` on the container (`cew:1758`), removed at detach. match self.mount { @@ -85,7 +83,7 @@ fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { }), ) // One service registration owns marker seeding plus the model-content and - // model-dispose watches. It is acquired before the Viewer content/will/token + // model-dispose watches. It is acquired before the CodeEditorWidget content/will/token // listeners so marker refresh and ModelDisposed handling run first; the // returned idempotent lease is released with this ModelData. listeners_to_remove.push( @@ -94,7 +92,7 @@ fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { // The `set_value` flush lane (`model.onDidChangeContent`). By the time // this fires, the registered view models already re-projected (the // model's two-loop dispatch runs before its public emitter), so the - // service registration already re-seeded marker decorations. The Viewer + // service registration already re-seeded marker decorations. The CodeEditorWidget // side re-flushes layout facts, re-fires the public event for contribs, and // schedules the repaint without acquiring another marker lease. listeners_to_remove.push( @@ -110,7 +108,7 @@ fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { } end_view_event_batch(event_batch) // Monaco cancels the pending mouse reaction on content change. The - // Viewer also retires hover provider/timer ownership and rotates the operation + // CodeEditorWidget also retires hover provider/timer ownership and rotates the operation // generation while preserving only an already settled hover until a new // pointer request replaces it. Incomplete/loading state is retired. match self.content_hover_controller() { @@ -132,11 +130,6 @@ fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { } None => () } - match self.current_markdown_browser_data() { - Some(markdown) => - markdown.replace_source(model.get_value(), model.get_version_id()) - None => () - } self.schedule_render() self.did_change_model_content.fire(event) self.complete_model_content_delivery(model, event.version_id) @@ -160,7 +153,7 @@ fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { view_model.line_count(), ) // The cursor outgoing alias is model-scoped. The ViewModel has already - // committed both cursor coordinate states before this fires; the Viewer + // committed both cursor coordinate states before this fires; the CodeEditorWidget // adds the public content barrier and position-before-selection relay. listeners_to_remove.push( view_model.on_cursor_state_changed(event => { @@ -216,24 +209,14 @@ fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { self.apply_scroll_change(Some(change)) }), ) - let browser = self.create_browser_presentation( - model, view_model, presentation_kind, model_data_generation, - ) + let browser = self.create_code_browser(model, view_model) self.model_slot.install_current({ model, view_model, - presentation_kind, browser, attached_view, listeners_to_remove, }) - match browser { - Some(presentation) => presentation.activate() - None => () - } - if browser is Some(Markdown(_)) { - self.scroll_dirty = true - } self.emit_view_event(ViewFlushed) self.apply_view_model_layout(view_model) let build_finished = @base_browser.monotonic_now_ms() @@ -251,72 +234,25 @@ fn Viewer::attach_model(self : Viewer, model : @model.TextModel) -> Unit { ///| /// Whether a swap-scoped callback still belongs to the installed ModelData. -fn Viewer::is_current_model_data_generation( - self : Viewer, +fn CodeEditorWidget::is_current_model_data_generation( + self : CodeEditorWidget, generation : Int, ) -> Bool { self.model_slot.is_current_generation(generation) } ///| -/// Build the selected browser presentation inside the shared container. -/// Markdown owns one document root and native scroll viewport. Code follows -/// Monaco's `_createView` + widget re-add loop (`cew:1889-1912`), retaining its -/// MouseHandler and empty render/reveal facts. `None` while headless. -fn Viewer::create_browser_presentation( - self : Viewer, +/// Build the browser code view inside the shared container. `None` while +/// headless. +fn CodeEditorWidget::create_code_browser( + self : CodeEditorWidget, model : @model.TextModel, view_model : @view_model.ViewModel, - presentation_kind : DocumentPresentationKind, - model_data_generation : Int, -) -> BrowserPresentation? { +) -> CodeBrowserData? { let mounted = match self.mount { Headless => return None Mounted(mounted) => mounted } - match presentation_kind { - MarkdownDocument(resource_kind) => { - let markdown = @markdown_document.MarkdownDocumentView( - model.get_value(), - model.get_version_id(), - model.get_language_id(), - model.uri, - resource_kind, - self.configuration.snapshot().options.theme, - () => self.on_markdown_document_scroll(), - focused => self.on_markdown_document_focus_changed(focused), - () => self.on_markdown_document_size_changed(), - show_unused=self.configuration.snapshot().options.show_unused, - ) - mounted.install_presentation_root(markdown.root()) - let hover_bridge = @hover_browser.MarkdownDocumentHoverBridge( - markdown, - model, - self.make_hover_computer(), - self.services.marker_decorations, - model_data_generation, - () => { - self.is_current_model_data_generation(model_data_generation) && - self.is_current_model(model) - }, - launch_async_task, - ) - let definition_bridge = MarkdownDocumentDefinitionBridge( - self, markdown, model, model_data_generation, - ) - let markdown_data : MarkdownBrowserData = { - view: markdown, - hover_bridge, - definition_bridge, - fold: MarkdownFoldState(), - } - // Seed fold state (auto-fold policy plus controls) before the bridge is - // activated, so the first activation already sees final visibility. - markdown_data.initialize_markdown_folding(model.get_value()) - return Some(Markdown(markdown_data)) - } - CodeDocument => () - } let read_view_zones_configuration = () => { self.configuration .snapshot() @@ -418,12 +354,12 @@ fn Viewer::create_browser_presentation( } // `data-uri` (`cew:1912`). view.root.set_attribute("data-uri", model.uri.to_string()) - Some(Code(CodeBrowserData(view, mouse_handler))) + Some(CodeBrowserData(view, mouse_handler)) } ///| -fn Viewer::build_view_model( - self : Viewer, +fn CodeEditorWidget::build_view_model( + self : CodeEditorWidget, model : @model.TextModel, attached_view : @model.AttachedViewImpl, ) -> @view_model.ViewModel { @@ -465,8 +401,8 @@ fn Viewer::build_view_model( /// The root half of the ViewModel's outgoing model-token event. The internal /// browser ViewEvent has already completed; telemetry reads stored counts /// passively and the next frame repaints without re-warming the lexer. -fn Viewer::handle_model_tokens_changed( - self : Viewer, +fn CodeEditorWidget::handle_model_tokens_changed( + self : CodeEditorWidget, model : @model.TextModel, _event : @model.ModelTokensChangedEvent, ) -> Unit { @@ -491,9 +427,9 @@ fn Viewer::handle_model_tokens_changed( /// Monaco's `_configuration.onDidChangeFast` → `_onConfigurationChanged` /// subscription. A changed view-line mapping re-flushes the layout; anything /// else only re-syncs the content width. -fn Viewer::on_configuration_changed( - self : Viewer, - previous_options? : ViewerOptions = self.configuration.snapshot().options, +fn CodeEditorWidget::on_configuration_changed( + self : CodeEditorWidget, + previous_options? : CodeEditorOptions = self.configuration.snapshot().options, ) -> Unit { let event_batch = self.begin_view_event_batch() match (self.get_view_model(), self.get_model()) { @@ -536,8 +472,8 @@ fn Viewer::on_configuration_changed( /// the content stale — Monaco's `ViewFlushedEvent`/ /// `ViewLineMappingChangedEvent` handling (`viewLayout.onFlushed`; the view /// model already re-pointed its own cursor). -fn Viewer::on_line_mapping_changed( - self : Viewer, +fn CodeEditorWidget::on_line_mapping_changed( + self : CodeEditorWidget, view_model : @view_model.ViewModel, model : @model.TextModel, notify_view? : Bool = true, @@ -595,8 +531,8 @@ fn Viewer::on_line_mapping_changed( } ///| -fn Viewer::apply_view_model_layout( - self : Viewer, +fn CodeEditorWidget::apply_view_model_layout( + self : CodeEditorWidget, view_model : @view_model.ViewModel, ) -> Unit { view_model.view_layout.set_line_count(view_model.line_count()) |> ignore diff --git a/editor/viewer/browser_host.mbt b/editor/internal/viewer/code_editor_widget/browser_host.mbt similarity index 100% rename from editor/viewer/browser_host.mbt rename to editor/internal/viewer/code_editor_widget/browser_host.mbt diff --git a/editor/viewer/browser_host_wbtest.mbt b/editor/internal/viewer/code_editor_widget/browser_host_wbtest.mbt similarity index 100% rename from editor/viewer/browser_host_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/browser_host_wbtest.mbt diff --git a/editor/viewer/content_hover_widget_host.mbt b/editor/internal/viewer/code_editor_widget/content_hover_widget_host.mbt similarity index 89% rename from editor/viewer/content_hover_widget_host.mbt rename to editor/internal/viewer/code_editor_widget/content_hover_widget_host.mbt index 2e01140ff..ab0e1857d 100644 --- a/editor/viewer/content_hover_widget_host.mbt +++ b/editor/internal/viewer/code_editor_widget/content_hover_widget_host.mbt @@ -2,8 +2,8 @@ // `ContentHoverWidgetWrapper._showHover`/`_hideHover` (driving the persistent // `ContentHoverWidget` when `_setCurrentResult` changed the shown result) plus // the `ICodeEditor` surface the widget consumes (`addContentWidget` / -// `layoutContentWidget` / `render`), expressed as `Viewer::` glue beside the -// root-owned Viewer state. Keeping the contribution package below the facade +// `layoutContentWidget` / `render`), expressed as `CodeEditorWidget::` glue beside the +// root-owned CodeEditorWidget state. Keeping the contribution package below the facade // also avoids an internal-contribution -> root-viewer dependency. ///| @@ -11,7 +11,7 @@ /// derived view. Idempotent (dedupes on the `HoverWidgetView`), so every /// hover-state chokepoint may call it: Monaco's `_setCurrentResult` calls /// `_showHover`/`_hideHover` exactly when the result object changed. -fn Viewer::sync_content_hover_widget(self : Viewer) -> Unit { +fn CodeEditorWidget::sync_content_hover_widget(self : CodeEditorWidget) -> Unit { guard self.content_hover_contribution() is Some(state) else { return } let hc = state.controller let loading_message = if hc.hover.operation.shows_loading_message() { @@ -45,7 +45,7 @@ fn Viewer::sync_content_hover_widget(self : Viewer) -> Unit { /// Logical live visibility after participant-selected loading augmentation. /// This is the source of truth for controller scheduling/command predicates; /// it stays correct in headless tests where no DOM widget can be mounted. -fn Viewer::content_hover_visible(self : Viewer) -> Bool { +fn CodeEditorWidget::content_hover_visible(self : CodeEditorWidget) -> Bool { match self.content_hover_contribution() { Some(state) => state.widget_view is Some(_) None => false @@ -54,8 +54,8 @@ fn Viewer::content_hover_visible(self : Viewer) -> Bool { ///| /// Wrapper mouseleave: hide only when the pointer also left the editor root. -fn Viewer::on_content_hover_widget_mouse_leave( - self : Viewer, +fn CodeEditorWidget::on_content_hover_widget_mouse_leave( + self : CodeEditorWidget, posx : Double, posy : Double, ) -> Unit { @@ -82,8 +82,8 @@ fn Viewer::on_content_hover_widget_mouse_leave( /// (`computeHoverPositions` over the parts, with the anchor view line's /// minimum column as the `startColumnBoundary`) and hand the ordered parts to /// the browser widget, which owns their retained DOM rendering. -fn Viewer::rendered_content_hover( - self : Viewer, +fn CodeEditorWidget::rendered_content_hover( + self : CodeEditorWidget, view : @hover.HoverWidgetView, ) -> @hover_browser.RenderedContentHover { let placement_parts : Array[@hover.HoverPositionPart] = [] @@ -123,12 +123,12 @@ fn Viewer::rendered_content_hover( ///| /// Lazily constructs the widget and mounts it — Monaco's /// `ContentHoverWidget` constructor calling `_editor.addContentWidget(this)`. -/// The widget itself is created once per `Viewer`; each per-model `View` +/// The widget itself is created once per `CodeEditorWidget`; each per-model `View` /// re-adds it through the code presentation's widget re-add loop /// (`cew:1893-1899`). /// `None` before a view exists. -fn Viewer::ensure_content_hover_widget( - self : Viewer, +fn CodeEditorWidget::ensure_content_hover_widget( + self : CodeEditorWidget, ) -> @hover_browser.ContentHoverWidget? { guard self.content_hover_contribution() is Some(state) else { return None } match state.widget { @@ -181,7 +181,9 @@ fn Viewer::ensure_content_hover_widget( ///| /// Monaco `ICodeEditor.layoutContentWidget(widget)`: re-read the widget's /// `get_position()` into the content-widgets part and repaint. -fn Viewer::layout_content_hover_widget(self : Viewer) -> Unit { +fn CodeEditorWidget::layout_content_hover_widget( + self : CodeEditorWidget, +) -> Unit { guard self.current_code_view() is Some(view) else { return } guard self.content_hover_contribution() is Some(state) else { return } guard state.widget is Some(widget) else { return } @@ -192,8 +194,8 @@ fn Viewer::layout_content_hover_widget(self : Viewer) -> Unit { ///| /// Monaco `ICodeEditor.getScrolledVisiblePosition(position).top`: the model /// position's line top in editor-viewport pixels. -fn Viewer::scrolled_visible_position_top( - self : Viewer, +fn CodeEditorWidget::scrolled_visible_position_top( + self : CodeEditorWidget, position : @base_common.Position, ) -> Double { guard self.get_view_model() is Some(view_model) else { return 0.0 } diff --git a/editor/viewer/context_menu_contribution.mbt b/editor/internal/viewer/code_editor_widget/context_menu_contribution.mbt similarity index 61% rename from editor/viewer/context_menu_contribution.mbt rename to editor/internal/viewer/code_editor_widget/context_menu_contribution.mbt index 31ba06aed..41ab903f6 100644 --- a/editor/viewer/context_menu_contribution.mbt +++ b/editor/internal/viewer/code_editor_widget/context_menu_contribution.mbt @@ -6,30 +6,23 @@ ///| let context_menu_contribution_id : String = "editor.contrib.contextmenu" -///| -priv struct MarkdownContextMenuAnchor { - client_x : Double - client_y : Double -} - ///| priv struct ContextMenuContributionState { mut widget : @contextmenu_browser.ContextMenuWidget? - mut markdown_anchor : MarkdownContextMenuAnchor? mut disposed : Bool } ///| fn ContextMenuContributionState::ContextMenuContributionState() -> ContextMenuContributionState { - { widget: None, markdown_anchor: None, disposed: false } + { widget: None, disposed: false } } ///| /// The shell is browser-only and therefore created on first mounted show, -/// keeping headless Viewer construction DOM-free. +/// keeping headless CodeEditorWidget construction DOM-free. fn ContextMenuContributionState::get_or_create_widget( self : ContextMenuContributionState, - viewer : Viewer, + viewer : CodeEditorWidget, ) -> @contextmenu_browser.ContextMenuWidget? { if self.disposed { return None @@ -62,7 +55,6 @@ fn ContextMenuContributionState::dispose( None => () } self.widget = None - self.markdown_anchor = None } ///| @@ -71,23 +63,15 @@ fn register_context_menu_contribution( ) -> Unit { registry.register_editor_contribution( context_menu_contribution_id, + ContextMenu, BeforeFirstInteraction, _viewer => ContextMenu(ContextMenuContributionState()), viewer => { [ viewer.on_context_menu(event => viewer.on_editor_context_menu(event)), - viewer.on_did_change_model(_ => { - viewer.clear_markdown_context_menu_anchor() - viewer.hide_context_menu() - }), - viewer.on_did_change_model_content(_ => { - viewer.clear_markdown_context_menu_anchor() - viewer.hide_context_menu() - }), - viewer.on_did_scroll_change(_ => { - viewer.clear_markdown_context_menu_anchor() - viewer.hide_context_menu() - }), + viewer.on_did_change_model(_ => viewer.hide_context_menu()), + viewer.on_did_change_model_content(_ => viewer.hide_context_menu()), + viewer.on_did_scroll_change(_ => viewer.hide_context_menu()), ] }, ) @@ -102,8 +86,8 @@ fn register_context_menu_contribution( } ///| -fn Viewer::context_menu_contribution( - self : Viewer, +fn CodeEditorWidget::context_menu_contribution( + self : CodeEditorWidget, ) -> ContextMenuContributionState? { match self.contributions.lookup(context_menu_contribution_id) { Some(entry) => @@ -116,14 +100,16 @@ fn Viewer::context_menu_contribution( } ///| -fn Viewer::code_context_menu_available(self : Viewer) -> Bool { +fn CodeEditorWidget::code_context_menu_available( + self : CodeEditorWidget, +) -> Bool { !self.disposed && self.get_model() is Some(_) && self.current_code_browser_data() is Some(_) } ///| -fn Viewer::hide_context_menu(self : Viewer) -> Unit { +fn CodeEditorWidget::hide_context_menu(self : CodeEditorWidget) -> Unit { match self.context_menu_contribution() { Some(state) => match state.widget { @@ -134,48 +120,13 @@ fn Viewer::hide_context_menu(self : Viewer) -> Unit { } } -///| -fn Viewer::context_menu_is_visible(self : Viewer) -> Bool { - match self.context_menu_contribution() { - Some(state) => - match state.widget { - Some(widget) => widget.is_visible() - None => false - } - None => false - } -} - -///| -fn Viewer::clear_markdown_context_menu_anchor(self : Viewer) -> Unit { - match self.context_menu_contribution() { - Some(state) => state.markdown_anchor = None - None => () - } -} - -///| -fn Viewer::record_markdown_context_menu_anchor( - self : Viewer, - target : @definition.DefinitionTargetFingerprint?, - client_x : Double, - client_y : Double, -) -> Unit { - guard self.context_menu_contribution() is Some(state) else { return } - state.markdown_anchor = match target { - Some(target) if self.definition_link_target_is_current(target) => - Some({ client_x, client_y }) - _ => None - } -} - ///| /// `_onContextMenu`: only text and content-empty hits enter the custom menu. /// Injected text, widgets, scrollbars, margins, zones, and unknown regions /// retain the browser's native menu because this readonly port has no actions /// for those surfaces. -fn Viewer::on_editor_context_menu( - self : Viewer, +fn CodeEditorWidget::on_editor_context_menu( + self : CodeEditorWidget, event : @editor_browser.EditorMouseEvent, ) -> Unit { guard self.code_context_menu_available() else { return } @@ -225,15 +176,15 @@ fn Viewer::on_editor_context_menu( } ///| -fn Viewer::show_context_menu_at( - self : Viewer, +fn CodeEditorWidget::show_context_menu_at( + self : CodeEditorWidget, client_x : Double, client_y : Double, auto_select_first? : Bool = false, ) -> Bool { guard self.context_menu_contribution() is Some(state) else { return false } guard state.get_or_create_widget(self) is Some(widget) else { return false } - guard self.current_browser_presentation() is Some(presentation) else { + guard self.current_code_browser_data() is Some(presentation) else { return false } let entries = editor_registry().resolve_editor_menu(self, EditorContext) @@ -250,7 +201,9 @@ fn Viewer::show_context_menu_at( ///| /// Keyboard invocation anchors under the rendered Code cursor after a /// synchronous flush, matching Monaco's reveal/render/visible-position path. -fn Viewer::show_code_context_menu_from_keyboard(self : Viewer) -> Bool { +fn CodeEditorWidget::show_code_context_menu_from_keyboard( + self : CodeEditorWidget, +) -> Bool { guard self.code_context_menu_available() else { return false } guard self.get_position() is Some(position) else { return false } self.reveal_position(position) @@ -258,7 +211,7 @@ fn Viewer::show_code_context_menu_from_keyboard(self : Viewer) -> Bool { guard self.get_scrolled_visible_position(position) is Some(cursor) else { return false } - guard self.current_browser_presentation() is Some(presentation) else { + guard self.current_code_browser_data() is Some(presentation) else { return false } let rect = presentation.root().get_bounding_client_rect() @@ -268,47 +221,3 @@ fn Viewer::show_code_context_menu_from_keyboard(self : Viewer) -> Bool { auto_select_first=true, ) } - -///| -/// A semantic Markdown row supplies its original-model target before the menu -/// is resolved. Ordinary Markdown and projected padding return false so their -/// browser-native context menu remains untouched. -fn Viewer::show_markdown_context_menu( - self : Viewer, - target : @definition.DefinitionTargetFingerprint?, - client_x : Double, - client_y : Double, -) -> Bool { - guard self.current_markdown_document() is Some(_) && - target is Some(target) && - self.definition_link_target_is_current(target) else { - self.reset_definition_link() - return false - } - guard editor_registry().resolve_editor_menu(self, EditorContext).length() > 0 else { - self.reset_definition_link() - return false - } - self.record_markdown_context_menu_anchor(Some(target), client_x, client_y) - self.commit_plain_definition_pointer_target(Some(target)) - self.focus() - self.show_context_menu_at(client_x, client_y) -} - -///| -/// The Markdown presentation has no Code cursor geometry. Its keyboard menu -/// therefore reuses the most recent still-valid semantic pointer coordinate, -/// which is the same projected source anchor used by F12. -fn Viewer::show_markdown_context_menu_from_keyboard(self : Viewer) -> Bool { - guard self.current_markdown_document() is Some(_) && - self.definition_command_position() is Some(_) && - self.context_menu_contribution() is Some(state) && - state.markdown_anchor is Some(anchor) else { - return false - } - self.show_context_menu_at( - anchor.client_x, - anchor.client_y, - auto_select_first=true, - ) -} diff --git a/editor/internal/viewer/code_editor_widget/contribution_descriptors.mbt b/editor/internal/viewer/code_editor_widget/contribution_descriptors.mbt new file mode 100644 index 000000000..609aeb012 --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/contribution_descriptors.mbt @@ -0,0 +1,88 @@ +///| +/// Typed identity of one contribution description in the process-wide editor +/// registry. This is deliberately not an editor "role": construction selects +/// an immutable set of independent capabilities, and runtime feature code +/// discovers availability from the installed contribution itself. +priv enum CodeEditorContributionKind { + AgentFeedbackInput + AgentFeedbackWidget + Folding + MarkdownComments + ContentHover + QuickDiff + ContextMenu + Definition + References +} derive(Eq) + +///| +/// Explicit contribution set for one code-editor kernel. The fields stay +/// opaque outside this package so hosts choose a reviewed factory rather than +/// rebuilding a second contribution policy from booleans or editor roles. +pub struct CodeEditorContributionDescriptors { + priv enabled : Array[CodeEditorContributionKind] +} + +///| +fn CodeEditorContributionDescriptors::from_enabled( + enabled : Array[CodeEditorContributionKind], +) -> CodeEditorContributionDescriptors { + { enabled, } +} + +///| +fn CodeEditorContributionDescriptors::allows( + self : CodeEditorContributionDescriptors, + kind : CodeEditorContributionKind, +) -> Bool { + self.enabled.contains(kind) +} + +///| +/// Full standalone code-editor surface. This is the public `Viewer` policy: +/// all ordinary code contributions, including Markdown-comment projection and +/// source-control quick diff, are available. +pub fn CodeEditorContributionDescriptors::standalone() -> CodeEditorContributionDescriptors { + CodeEditorContributionDescriptors::from_enabled([ + AgentFeedbackInput, + AgentFeedbackWidget, + Folding, + MarkdownComments, + ContentHover, + QuickDiff, + ContextMenu, + Definition, + References, + ]) +} + +///| +/// Diff-pane code-editor surface. It keeps ordinary navigation, folding, +/// hover, context menu, references, and agent feedback. Markdown-comment rich +/// replacement is excluded so both panes retain source-line geometry; quick +/// diff is excluded because a diff pane must not host a nested SCM diff. +pub fn CodeEditorContributionDescriptors::diff_pane() -> CodeEditorContributionDescriptors { + CodeEditorContributionDescriptors::from_enabled([ + AgentFeedbackInput, + AgentFeedbackWidget, + Folding, + ContentHover, + ContextMenu, + Definition, + References, + ]) +} + +///| +/// Embedded Definition/References Peek preview. The preview retains hover, +/// context actions, and direct Go to Definition, but intentionally has no +/// References controller, Markdown-comment replacement, quick diff, folding, +/// or agent-feedback UI. In particular, Alt/Shift+F12 cannot recursively open +/// another Peek because no References contribution exists in this kernel. +pub fn CodeEditorContributionDescriptors::peek_preview() -> CodeEditorContributionDescriptors { + CodeEditorContributionDescriptors::from_enabled([ + ContentHover, + ContextMenu, + Definition, + ]) +} diff --git a/editor/viewer/controller_host.mbt b/editor/internal/viewer/code_editor_widget/controller_host.mbt similarity index 95% rename from editor/viewer/controller_host.mbt rename to editor/internal/viewer/code_editor_widget/controller_host.mbt index c61a5d183..2a4e350bf 100644 --- a/editor/viewer/controller_host.mbt +++ b/editor/internal/viewer/code_editor_widget/controller_host.mbt @@ -4,11 +4,11 @@ // `ViewContext`/`ViewController` reads the helper substitutes for (the // helper's header in `mouse_handler.mbt` records the grouping). The closures // capture the view and the viewer so the controller never names the concrete -// `View`/`Viewer`. +// `View`/`CodeEditorWidget`. ///| -fn Viewer::pointer_handler_helper( - self : Viewer, +fn CodeEditorWidget::pointer_handler_helper( + self : CodeEditorWidget, view : @view.View, ) -> @controller.PointerHandlerHelper { { @@ -81,7 +81,7 @@ fn Viewer::pointer_handler_helper( ///| /// Cancels an in-progress mouse selection on viewer lifecycle resets (model /// change/clear). No-op before the view (and its controller) exist. -fn Viewer::cancel_mouse_selection(self : Viewer) -> Unit { +fn CodeEditorWidget::cancel_mouse_selection(self : CodeEditorWidget) -> Unit { match self.current_code_browser_data() { Some(browser) => browser.cancel_mouse_selection() None => () diff --git a/editor/viewer/cursor_behavior_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_behavior_wbtest.mbt similarity index 96% rename from editor/viewer/cursor_behavior_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_behavior_wbtest.mbt index ea63d50c5..6c41f3071 100644 --- a/editor/viewer/cursor_behavior_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_behavior_wbtest.mbt @@ -204,7 +204,7 @@ test "set_selection fires cursor events only when the state changes" { /// "abcdefghij" wrapped at column 6 puts model column 7 on view line 2, so /// the selection overlay's view range differs from the model selection. test "set_selection re-derives the view state under wrap" { - let options = ViewerOptions(soft_wrap=true) + let options = CodeEditorOptions(soft_wrap=true) with_test_viewer( "abcdefghij", viewer => { @@ -228,7 +228,7 @@ test "set_selection re-derives the view state under wrap" { /// The no-model guard (cew:671-673, :825-827): without a model both setters /// are no-ops and no cursor event fires. test "set_position and set_selection are no-ops without a model" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let mut fires = 0 viewer.on_did_change_cursor_position(_ => fires += 1) |> ignore viewer.on_did_change_cursor_selection(_ => fires += 1) |> ignore @@ -243,9 +243,9 @@ test "set_position and set_selection are no-ops without a model" { /// A wrapping-width change re-projects the live selection *inside* the view /// model (`_cursor.onLineMappingChanged`, `viewModelImpl.ts:287`): the model /// selection is unchanged and the view selection lands on the new projection -/// with no Viewer-side re-point call in between. +/// with no CodeEditorWidget-side re-point call in between. test "wrap-width change re-projects the selection inside the view model" { - let options = ViewerOptions(soft_wrap=true) + let options = CodeEditorOptions(soft_wrap=true) with_test_viewer( "abcdefghij", viewer => { @@ -256,7 +256,7 @@ test "wrap-width change re-projects the selection inside the view model" { viewer.test_cursor().get_view_selection().get_position(), Position(2, 3), ) - // Reflow to a narrower column through the Viewer/ViewModel path; the + // Reflow to a narrower column through the CodeEditorWidget/ViewModel path; the // headless harness observes the same committed cursor reprojection. viewer.test_set_soft_wrap_column(4) assert_selection_eq( diff --git a/editor/viewer/cursor_command_shape_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_command_shape_wbtest.mbt similarity index 98% rename from editor/viewer/cursor_command_shape_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_command_shape_wbtest.mbt index 2f357f6ed..72e907702 100644 --- a/editor/viewer/cursor_command_shape_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_command_shape_wbtest.mbt @@ -17,7 +17,7 @@ test "partial core command args fall back to empty and return before mutation" { ///| test "move core command accepts absent and supplied view positions" { - let options = ViewerOptions(soft_wrap=true) + let options = CodeEditorOptions(soft_wrap=true) with_test_viewer( "abcdefghij", viewer => { @@ -87,7 +87,7 @@ test "view controller selection adapters preserve exact keyboard and mouse sourc ///| test "selectAll returns without a view and hard-codes keyboard source" { - let detached = Viewer::Viewer() + let detached = CodeEditorWidget::CodeEditorWidget() assert_false(detached.run_select_all_core_command()) detached.dispose() diff --git a/editor/viewer/cursor_commands.mbt b/editor/internal/viewer/code_editor_widget/cursor_commands.mbt similarity index 95% rename from editor/viewer/cursor_commands.mbt rename to editor/internal/viewer/code_editor_widget/cursor_commands.mbt index 809f5b979..b433bcb96 100644 --- a/editor/viewer/cursor_commands.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_commands.mbt @@ -163,7 +163,7 @@ fn BaseMoveToCommand::as_core_editor_command( /// all cursor work after that boundary is retained in source order. fn BaseMoveToCommand::run( self : BaseMoveToCommand, - viewer : Viewer, + viewer : CodeEditorWidget, view_model : @view_model.ViewModel, core_args : CursorCoreCommandArgs, ) -> Bool { @@ -221,7 +221,7 @@ fn CursorMoveBasedCommand::as_core_editor_command( ///| fn CursorMoveBasedCommand::run( self : CursorMoveBasedCommand, - viewer : Viewer, + viewer : CodeEditorWidget, _ : @view_model.ViewModel, core_args : CursorCoreCommandArgs, ) -> Bool { @@ -584,8 +584,8 @@ fn SetSelectionCommand::as_core_editor_command( } ///| -fn Viewer::run_move_to_core_command( - self : Viewer, +fn CodeEditorWidget::run_move_to_core_command( + self : CodeEditorWidget, view_model : @view_model.ViewModel, args : CursorMoveCommandArgs?, in_selection_mode : Bool, @@ -604,8 +604,8 @@ fn Viewer::run_move_to_core_command( } ///| -fn Viewer::run_word_core_command( - self : Viewer, +fn CodeEditorWidget::run_word_core_command( + self : CodeEditorWidget, view_model : @view_model.ViewModel, args : CursorMoveCommandArgs?, in_selection_mode : Bool, @@ -624,8 +624,8 @@ fn Viewer::run_word_core_command( } ///| -fn Viewer::run_line_core_command( - self : Viewer, +fn CodeEditorWidget::run_line_core_command( + self : CodeEditorWidget, view_model : @view_model.ViewModel, args : CursorMoveCommandArgs?, in_selection_mode : Bool, @@ -644,8 +644,8 @@ fn Viewer::run_line_core_command( } ///| -fn Viewer::run_set_selection_core_command( - self : Viewer, +fn CodeEditorWidget::run_set_selection_core_command( + self : CodeEditorWidget, args : CursorSetSelectionCommandArgs?, ) -> Bool { guard self.get_view_model() is Some(view_model) else { return false } @@ -664,8 +664,8 @@ fn Viewer::run_set_selection_core_command( ///| /// ViewController's programmatic setSelection adapter: source is exactly /// `keyboard` (`viewController.ts:94-99`). -fn Viewer::set_selection_from_view_controller( - self : Viewer, +fn CodeEditorWidget::set_selection_from_view_controller( + self : CodeEditorWidget, selection : @core.Selection, ) -> Bool { let options = SetSelectionCommandOptions::new(selection, source="keyboard") @@ -677,8 +677,8 @@ fn Viewer::set_selection_from_view_controller( ///| /// ViewController's `_select` adapter: source is exactly `mouse` /// (`viewController.ts:384-386`). -fn Viewer::select_from_view_controller( - self : Viewer, +fn CodeEditorWidget::select_from_view_controller( + self : CodeEditorWidget, selection : @core.Selection, ) -> Bool { let options = SetSelectionCommandOptions::new(selection, source="mouse") @@ -692,10 +692,10 @@ fn Viewer::select_from_view_controller( /// Their current live pointer paths reach the same registered SetSelection /// command through MoveTo/Word/Line, while the adapters remain part of the /// source-shaped ViewController command surface. -let _view_controller_set_selection_anchor : (Viewer, @core.Selection) -> Bool = Viewer::set_selection_from_view_controller +let _view_controller_set_selection_anchor : (CodeEditorWidget, @core.Selection) -> Bool = CodeEditorWidget::set_selection_from_view_controller ///| -let _view_controller_select_anchor : (Viewer, @core.Selection) -> Bool = Viewer::select_from_view_controller +let _view_controller_select_anchor : (CodeEditorWidget, @core.Selection) -> Bool = CodeEditorWidget::select_from_view_controller ///| /// Registers Left/Right/Up/Down/PageUp/PageDown/Home/End, each crossed with @@ -904,7 +904,7 @@ fn register_cursor_key_command( key : String, shift~ : Bool, ) -> Unit { - let keybinding_when = (viewer : Viewer) => { + let keybinding_when = (viewer : CodeEditorWidget) => { viewer.is_code_presentation() && viewer.get_view_model() is Some(_) } if shift { @@ -921,8 +921,8 @@ fn register_cursor_key_command( ///| /// Runs one character/wrapped-line command and always reveals after a winning /// registration, even when the movement is a hard-boundary no-op. -fn Viewer::run_cursor_move( - self : Viewer, +fn CodeEditorWidget::run_cursor_move( + self : CodeEditorWidget, direction : @view_model.SimpleMoveDirection, in_selection_mode : Bool, value? : Int = 1, @@ -943,8 +943,8 @@ fn Viewer::run_cursor_move( /// Runs PageUp/PageDown with the source's exact static `-1` page marker. /// Omitted/static-marker and exact-zero values use the live configured page /// size; a positive dynamic value overrides it. -fn Viewer::run_cursor_page( - self : Viewer, +fn CodeEditorWidget::run_cursor_page( + self : CodeEditorWidget, direction : @view_model.SimpleMoveDirection, in_selection_mode : Bool, page_size? : Int = -1, @@ -963,8 +963,8 @@ fn Viewer::run_cursor_page( } ///| -fn Viewer::run_cursor_home( - self : Viewer, +fn CodeEditorWidget::run_cursor_home( + self : CodeEditorWidget, in_selection_mode : Bool, source? : String = "keyboard", ) -> Bool { @@ -979,8 +979,8 @@ fn Viewer::run_cursor_home( } ///| -fn Viewer::run_cursor_end( - self : Viewer, +fn CodeEditorWidget::run_cursor_end( + self : CodeEditorWidget, in_selection_mode : Bool, sticky? : Bool = false, source? : String = "keyboard", @@ -997,8 +997,8 @@ fn Viewer::run_cursor_end( } ///| -fn Viewer::reveal_cursor_after_navigation( - self : Viewer, +fn CodeEditorWidget::reveal_cursor_after_navigation( + self : CodeEditorWidget, source : String, ) -> Unit { self.reveal_view_cursor( diff --git a/editor/viewer/cursor_event_delivery.mbt b/editor/internal/viewer/code_editor_widget/cursor_event_delivery.mbt similarity index 92% rename from editor/viewer/cursor_event_delivery.mbt rename to editor/internal/viewer/code_editor_widget/cursor_event_delivery.mbt index 3782491bc..ffa61e394 100644 --- a/editor/viewer/cursor_event_delivery.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_event_delivery.mbt @@ -8,9 +8,9 @@ fn is_model_flush_cursor_event( } ///| -/// Viewer-lifetime owner of the reentrant cursor-pair queue and the exact +/// CodeEditorWidget-lifetime owner of the reentrant cursor-pair queue and the exact /// content-version barriers that make model Flush delivery wait for its public -/// content event. Model identity and every observable effect stay on Viewer. +/// content event. Model identity and every observable effect stay on CodeEditorWidget. priv struct CursorEventDelivery { queue : Array[@view_model.CursorStateChangedEvent] mut is_draining : Bool @@ -76,7 +76,7 @@ fn CursorEventDelivery::begin_drain(self : CursorEventDelivery) -> Bool { ///| /// Removes the FIFO head only when it is ready. A blocked Flush never permits /// a later queue item to overtake it. Completion membership is consumed before -/// the event is returned to Viewer, and therefore before any public callback. +/// the event is returned to CodeEditorWidget, and therefore before any public callback. fn CursorEventDelivery::take_next_ready( self : CursorEventDelivery, ) -> @view_model.CursorStateChangedEvent? { @@ -132,8 +132,8 @@ fn CursorEventDelivery::reset_model_scope(self : CursorEventDelivery) -> Unit { /// committed; scheduling happens before any public listener. Flush facts open /// a content barrier and remain queued until the model's public content event /// has run. -fn Viewer::enqueue_cursor_state_change( - self : Viewer, +fn CodeEditorWidget::enqueue_cursor_state_change( + self : CodeEditorWidget, event : @view_model.CursorStateChangedEvent, ) -> Unit { // `set_selections` publicly accepts any reason, including ContentFlush. @@ -148,8 +148,8 @@ fn Viewer::enqueue_cursor_state_change( /// Closes one model-content barrier after all public content listeners return. /// Nested/reentrant set-value calls increment the depth independently, so no /// flush fact can overwrite another or drain before its content event. -fn Viewer::complete_model_content_delivery( - self : Viewer, +fn CodeEditorWidget::complete_model_content_delivery( + self : CodeEditorWidget, model : @model.TextModel, version_id : Int, ) -> Unit { @@ -164,7 +164,9 @@ fn Viewer::complete_model_content_delivery( /// Drains cursor facts FIFO. A fact is removed before callbacks, and the gate /// remains set through both public fires, so a reentrant cursor transition is /// appended for a later iteration rather than interleaving the current pair. -fn Viewer::drain_cursor_delivery_queue(self : Viewer) -> Unit { +fn CodeEditorWidget::drain_cursor_delivery_queue( + self : CodeEditorWidget, +) -> Unit { if !self.cursor_delivery.begin_drain() { return } diff --git a/editor/viewer/cursor_input_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_input_wbtest.mbt similarity index 98% rename from editor/viewer/cursor_input_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_input_wbtest.mbt index 1c5791808..9de6db230 100644 --- a/editor/viewer/cursor_input_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_input_wbtest.mbt @@ -4,7 +4,7 @@ ///| fn dispatch_navigation_key( - viewer : Viewer, + viewer : CodeEditorWidget, key : String, shift? : Bool = false, ) -> Bool { @@ -54,7 +54,7 @@ fn assert_last_cursor_transition( } ///| -fn reset_cursor_mouse_reveal(viewer : Viewer) -> Unit { +fn reset_cursor_mouse_reveal(viewer : CodeEditorWidget) -> Unit { viewer.set_scroll_position(scroll_top=0.0, scroll_left=0.0) match viewer.current_code_browser_data() { Some(browser) => browser.clear_horizontal_reveal() @@ -192,7 +192,7 @@ test "cursor command wins at a hard boundary and reveals its result" { ///| test "cursor key precondition and exact chords fall through when unmatched" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() assert_false(dispatch_navigation_key(viewer, "ArrowLeft")) viewer.set_model(Some(test_model("abc"))) assert_false( @@ -461,7 +461,7 @@ test "cursor Smooth reveal downgrades an exact one-line vertical delta" { ///| test "line drag keeps wrapped injected-text view position authoritative" { - let options = ViewerOptions(soft_wrap=true) + let options = CodeEditorOptions(soft_wrap=true) with_test_viewer( ( #|a @@ -593,7 +593,7 @@ test "shift gutter forward from Simple uses line continuation" { ///| fn install_cursor_test_injected_text( - viewer : Viewer, + viewer : CodeEditorWidget, position : @base_common.Position, content : String, ) -> Unit { diff --git a/editor/viewer/cursor_navigation_matrix_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_navigation_matrix_wbtest.mbt similarity index 99% rename from editor/viewer/cursor_navigation_matrix_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_navigation_matrix_wbtest.mbt index 1f24a04d6..a1023ce33 100644 --- a/editor/viewer/cursor_navigation_matrix_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_navigation_matrix_wbtest.mbt @@ -4,7 +4,7 @@ ///| test "Home End cover whitespace empty wrapped continuation and boundaries" { - let options = ViewerOptions(soft_wrap=true) + let options = CodeEditorOptions(soft_wrap=true) with_test_viewer( " \n\n abcdefghij", viewer => { diff --git a/editor/viewer/cursor_pointer_event_matrix_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_pointer_event_matrix_wbtest.mbt similarity index 98% rename from editor/viewer/cursor_pointer_event_matrix_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_pointer_event_matrix_wbtest.mbt index e97a8088d..0edd18e65 100644 --- a/editor/viewer/cursor_pointer_event_matrix_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_pointer_event_matrix_wbtest.mbt @@ -4,7 +4,7 @@ ///| fn assert_pointer_transition( - viewer : Viewer, + viewer : CodeEditorWidget, events : Array[@editor_api.CursorSelectionChangedEvent], previous_length : Int, old_selection : @core.Selection, @@ -167,7 +167,7 @@ test "ctrl and meta pointer modifiers retain ordinary and non-multi behavior" { ///| test "pointer converts a wrapped injected position across a hidden model line" { - let options = ViewerOptions(soft_wrap=true) + let options = CodeEditorOptions(soft_wrap=true) with_test_viewer( ( #|abcdefghij diff --git a/editor/viewer/cursor_reference_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_reference_wbtest.mbt similarity index 100% rename from editor/viewer/cursor_reference_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_reference_wbtest.mbt diff --git a/editor/viewer/cursor_tokenization_reference_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_tokenization_reference_wbtest.mbt similarity index 93% rename from editor/viewer/cursor_tokenization_reference_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_tokenization_reference_wbtest.mbt index 3b6f0d840..4d1e12dcc 100644 --- a/editor/viewer/cursor_tokenization_reference_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_tokenization_reference_wbtest.mbt @@ -27,7 +27,7 @@ impl @syntax.LineTokenizer for CursorTokenizationCountingTokenizer with fn token } ///| -/// The readonly Viewer has no incremental `applyEdits` lane, so the upstream +/// The readonly CodeEditorWidget has no incremental `applyEdits` lane, so the upstream /// insertion is adapted to `set_value`. Expanding one line to three makes a /// stale sibling projection observable while retaining the source regression: /// a cursor callback requests cheap tokenization during model content delivery. @@ -38,8 +38,8 @@ test "issue #46314: ViewModel is out of sync with Model!" { calls, }) let model = test_model("Just text", language_id~) - let first = Viewer::Viewer() - let second = Viewer::Viewer() + let first = CodeEditorWidget::CodeEditorWidget() + let second = CodeEditorWidget::CodeEditorWidget() first.set_model(Some(model)) second.set_model(Some(model)) first.set_position(Position(1, 5)) diff --git a/editor/viewer/cursor_transition_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_transition_wbtest.mbt similarity index 97% rename from editor/viewer/cursor_transition_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_transition_wbtest.mbt index 957b11890..a498c8f9f 100644 --- a/editor/viewer/cursor_transition_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_transition_wbtest.mbt @@ -19,7 +19,7 @@ fn cursor_transition_model( ///| test "set_selections without a model is an event-free no-op" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let mut positions = 0 let mut selections = 0 viewer.on_did_change_cursor_position(_ => positions += 1) |> ignore @@ -46,7 +46,7 @@ test "empty set_selections is an event-free no-op" { ///| test "cursor transition metadata uses internal model version for api keyboard and mouse" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let model = cursor_transition_model("alpha\nbeta") viewer.set_model(Some(model)) let selections : Array[@editor_api.CursorSelectionChangedEvent] = [] @@ -134,8 +134,8 @@ test "exact cursor state is a render and public event no-op" { } ///| -test "ready empty Flush is consumed before Viewer skips public cursor emission" { - let viewer = Viewer::Viewer() +test "ready empty Flush is consumed before CodeEditorWidget skips public cursor emission" { + let viewer = CodeEditorWidget::CodeEditorWidget() let mut positions = 0 let mut selections = 0 viewer.on_did_change_cursor_position(_ => positions += 1) |> ignore @@ -169,7 +169,7 @@ test "ready empty Flush is consumed before Viewer skips public cursor emission" ///| test "ContentFlush emits content then position then selection with model source and internal version" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let model = cursor_transition_model("one\ntwo") viewer.set_model(Some(model)) viewer.set_position(Position(2, 2)) @@ -253,7 +253,7 @@ test "cursor delivery FIFO keeps reentrant position and selection pairs adjacent ///| test "model swap in a position listener keeps the old pair ahead of the new model pair" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let first = test_model("abcdef") let second = test_model("uvwxyz") viewer.set_model(Some(first)) @@ -387,7 +387,7 @@ test "nested content completion releases only its matching Flush version" { ///| test "old-model content completion cannot release a same-version new-model Flush" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let first = test_model("first") let second = test_model("second") viewer.set_model(Some(first)) diff --git a/editor/viewer/cursor_view_reveal_wbtest.mbt b/editor/internal/viewer/code_editor_widget/cursor_view_reveal_wbtest.mbt similarity index 96% rename from editor/viewer/cursor_view_reveal_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/cursor_view_reveal_wbtest.mbt index 859325d73..8c49e0c94 100644 --- a/editor/viewer/cursor_view_reveal_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/cursor_view_reveal_wbtest.mbt @@ -19,8 +19,8 @@ test "cursor navigation reveal keeps the authoritative injected-text view positi }, ]) |> ignore - let viewer = Viewer::Viewer( - options=ViewerOptions( + let viewer = CodeEditorWidget::CodeEditorWidget( + options=CodeEditorOptions( soft_wrap=true, tab_size=4, wrapping_indent=Indent, diff --git a/editor/viewer/debug.mbt b/editor/internal/viewer/code_editor_widget/debug.mbt similarity index 74% rename from editor/viewer/debug.mbt rename to editor/internal/viewer/code_editor_widget/debug.mbt index 91cf893c3..3bad81179 100644 --- a/editor/viewer/debug.mbt +++ b/editor/internal/viewer/code_editor_widget/debug.mbt @@ -1,14 +1,14 @@ // Root-private instrumentation remains only for white-box assertions. Browser -// tests and the workbench subscribe through the Viewer-id-keyed +// tests and the workbench subscribe through the CodeEditorWidget-id-keyed // `internal/viewer/browser/testing` registry, so none of these hooks or -// payloads leak through the public Viewer facade. +// payloads leak through the public CodeEditorWidget facade. ///| /// Fires after a model's content is prepared for rendering (view-model build /// at attach, token re-warm on tokenizer changes) — tokenize/build timings /// and counts for telemetry and test synchronization. fn debug_on_did_build_view_model( - viewer : Viewer, + viewer : CodeEditorWidget, listener : (@viewer_testing.ViewModelBuiltObservation) -> Unit, ) -> @base_common.Disposable { viewer.did_build_view_model.event(listener) @@ -19,8 +19,8 @@ fn debug_on_did_build_view_model( let _debug_build_test_seam_anchor : Unit = ignore(debug_on_did_build_view_model) ///| -fn Viewer::emit_view_model_built( - self : Viewer, +fn CodeEditorWidget::emit_view_model_built( + self : CodeEditorWidget, observation : @viewer_testing.ViewModelBuiltObservation, ) -> Unit { self.did_build_view_model.fire(observation) @@ -28,16 +28,16 @@ fn Viewer::emit_view_model_built( } ///| -fn Viewer::emit_model_rendered( - self : Viewer, +fn CodeEditorWidget::emit_model_rendered( + self : CodeEditorWidget, observation : @viewer_testing.ModelRenderedObservation, ) -> Unit { @viewer_testing.publish_model_rendered(self.get_id(), observation) } ///| -fn Viewer::emit_hover_resolved( - self : Viewer, +fn CodeEditorWidget::emit_hover_resolved( + self : CodeEditorWidget, observation : @viewer_testing.HoverResolvedObservation, ) -> Unit { @viewer_testing.publish_hover_resolved(self.get_id(), observation) @@ -45,10 +45,10 @@ fn Viewer::emit_hover_resolved( ///| /// Emits the internal scroll/render trace without exposing it through the -/// public Viewer facade. `publish_scroll_frame` remains allocation-free when +/// public CodeEditorWidget facade. `publish_scroll_frame` remains allocation-free when /// no browser-test/workbench listener is installed. -fn Viewer::emit_scroll_frame_phase( - self : Viewer, +fn CodeEditorWidget::emit_scroll_frame_phase( + self : CodeEditorWidget, phase : @viewer_testing.ScrollFramePhase, scroll_top : Double, scroll_left : Double, diff --git a/editor/viewer/decorations_api.mbt b/editor/internal/viewer/code_editor_widget/decorations_api.mbt similarity index 91% rename from editor/viewer/decorations_api.mbt rename to editor/internal/viewer/code_editor_widget/decorations_api.mbt index ff15c3ec9..92ef1d275 100644 --- a/editor/viewer/decorations_api.mbt +++ b/editor/internal/viewer/code_editor_widget/decorations_api.mbt @@ -14,8 +14,8 @@ /// (`ownerId` = the viewer's editor id). Returns the new decoration ids. /// ModelDecorationOptions state lives on the model, so it follows the model across /// `set_model` swaps like Monaco's — re-apply on swap. -pub fn Viewer::delta_decorations( - self : Viewer, +pub fn CodeEditorWidget::delta_decorations( + self : CodeEditorWidget, old_decorations : Array[String], new_decorations : Array[@model.ModelDeltaDecoration], ) -> Array[String] { @@ -33,8 +33,8 @@ pub fn Viewer::delta_decorations( ///| /// `removeDecorations` (`codeEditorWidget.ts:1380-1387`): delta to empty /// through one `change_decorations` bracket. -pub fn Viewer::remove_decorations( - self : Viewer, +pub fn CodeEditorWidget::remove_decorations( + self : CodeEditorWidget, decoration_ids : Array[String], ) -> Unit { guard self.get_model() is Some(model) && decoration_ids.length() > 0 else { @@ -51,14 +51,14 @@ pub fn Viewer::remove_decorations( /// `delta_decorations` bound to one viewer. `onDidChange` and `has` are not /// ported (no consumer; the model-decoration event seam stays internal). struct EditorDecorationsCollection { - viewer : Viewer + viewer : CodeEditorWidget mut decoration_ids : Array[String] } ///| /// `createDecorationsCollection` (`codeEditorWidget.ts:1389`). -pub fn Viewer::create_decorations_collection( - self : Viewer, +pub fn CodeEditorWidget::create_decorations_collection( + self : CodeEditorWidget, decorations? : Array[@model.ModelDeltaDecoration] = [], ) -> EditorDecorationsCollection { let collection : EditorDecorationsCollection = { diff --git a/editor/viewer/definition_contribution.mbt b/editor/internal/viewer/code_editor_widget/definition_contribution.mbt similarity index 59% rename from editor/viewer/definition_contribution.mbt rename to editor/internal/viewer/code_editor_widget/definition_contribution.mbt index ee49450a3..576bcb152 100644 --- a/editor/viewer/definition_contribution.mbt +++ b/editor/internal/viewer/code_editor_widget/definition_contribution.mbt @@ -1,28 +1,9 @@ ///| let definition_contribution_id : String = "editor.contrib.definition" -///| -priv struct DefinitionActionStamp { - generation : Int - model_identity : Int - model_generation : Int - model_version_id : Int -} - -///| -priv struct DefinitionOpenStamp { - generation : Int - model_identity : Int - model_generation : Int - model_version_id : Int -} - ///| priv struct DefinitionContributionState { mut disposed : Bool - mut action_generation : Int - mut action_source : @language.CancellationTokenSource? - mut open_generation : Int mut link_generation : Int mut link_source : @language.CancellationTokenSource? mut link_state : @definition.DefinitionLinkState @@ -32,21 +13,38 @@ priv struct DefinitionContributionState { mut last_message : String? mut message_widget : @definition_browser.DefinitionMessageWidget? mut message_hide : @base_common.Disposable? - mut peek_source : @language.CancellationTokenSource? link_decorations : EditorDecorationsCollection - mut markdown_link_elements : Array[@rdom.Element] mut launch_async_task : (async () -> Unit noraise) -> Unit + navigation_action : @goto_symbol.SymbolNavigationAction + definition_action : @goto_symbol.DefinitionAction + references_action : @goto_symbol.ReferencesAction } ///| fn DefinitionContributionState::DefinitionContributionState( - viewer : Viewer, + viewer : CodeEditorWidget, ) -> DefinitionContributionState { + let navigation_action = @goto_symbol.SymbolNavigationAction( + viewer.services.languages, + { + prepare_navigation: () => viewer.prepare_symbol_navigation_intent(), + is_anchor_current: anchor => { + viewer.symbol_navigation_anchor_is_current(anchor) + }, + open_location: (_anchor, location, mode, source) => { + viewer.open_peek_location(location, mode, source) + }, + open_in_peek: (anchor, mode, locations) => { + viewer.open_symbol_navigation_peek(anchor, mode, locations) + }, + show_message: (anchor, message) => { + viewer.show_definition_message(message, position=anchor.position()) + }, + }, + run_async_tasks_concurrently, + ) { disposed: false, - action_generation: 0, - action_source: None, - open_generation: 0, link_generation: 0, link_source: None, link_state: LinkIdle, @@ -56,65 +54,11 @@ fn DefinitionContributionState::DefinitionContributionState( last_message: None, message_widget: None, message_hide: None, - peek_source: None, link_decorations: viewer.create_decorations_collection(), - markdown_link_elements: [], launch_async_task, - } -} - -///| -fn DefinitionContributionState::cancel_action( - self : DefinitionContributionState, -) -> Unit { - self.action_generation += 1 - let source = self.action_source - self.action_source = None - match source { - Some(source) => source.dispose(cancel=true) - None => () - } -} - -///| -fn DefinitionContributionState::invalidate_open_feedback( - self : DefinitionContributionState, -) -> Unit { - self.open_generation += 1 -} - -///| -/// Retires a completed or stale Definition Peek provider request only when it -/// is still the exact source owned by this state. -fn DefinitionContributionState::retire_peek_source( - self : DefinitionContributionState, - source : @language.CancellationTokenSource, - cancel~ : Bool, -) -> Unit { - match self.peek_source { - Some(current) if physical_equal(current, source) => { - self.peek_source = None - source.dispose(cancel~) - } - _ => () - } -} - -///| -/// Retires a completed or stale action only when it is still the exact request -/// owned by this state. A newer F12 request is therefore never cancelled by an -/// older continuation. -fn DefinitionContributionState::retire_action_source( - self : DefinitionContributionState, - source : @language.CancellationTokenSource, - cancel~ : Bool, -) -> Unit { - match self.action_source { - Some(current) if physical_equal(current, source) => { - self.action_source = None - source.dispose(cancel~) - } - _ => () + navigation_action, + definition_action: DefinitionAction(navigation_action), + references_action: ReferencesAction(navigation_action), } } @@ -127,10 +71,6 @@ fn DefinitionContributionState::cancel_link( self.link_source = None self.link_state = LinkIdle self.link_decorations.clear() - for element in self.markdown_link_elements { - @base_browser.remove_element(element) - } - self.markdown_link_elements.clear() match source { Some(source) => source.dispose(cancel=true) None => () @@ -174,13 +114,13 @@ fn DefinitionContributionState::cancel_link_gesture( ///| fn DefinitionContributionState::dispose( self : DefinitionContributionState, - viewer : Viewer, + viewer : CodeEditorWidget, ) -> Unit { if self.disposed { return } self.disposed = true - self.cancel_action() + self.navigation_action.dispose() self.reset_link_gesture() viewer.close_references_peek(restore_focus=false) viewer.dispose_definition_message() @@ -192,12 +132,14 @@ fn register_definition_contribution( ) -> Unit { registry.register_editor_contribution( definition_contribution_id, + Definition, BeforeFirstInteraction, viewer => Definition(DefinitionContributionState(viewer)), viewer => viewer.hook_definition_contribution(), ) + let reveal = @goto_symbol.RevealDefinition registry.register_editor_command( - "editor.action.revealDefinition", + reveal.id(), viewer => viewer.definition_action_available(), viewer => viewer.start_goto_definition(), [ @@ -205,25 +147,20 @@ fn register_definition_contribution( // VS Code's web fallback is the same Current-mode action. ("F12", false, true), ], - title="Go to Definition", - menu_keybinding_label=definition_menu_keybinding_label( - alt=false, - ctrl_cmd=false, - ), + title=reveal.title(), + menu_keybinding_label=reveal.keybinding_label().unwrap(), menu_placements=[ { menu_id: EditorContext, group: "navigation", order: 1.1 }, ], ) + let peek = @goto_symbol.PeekDefinition registry.register_editor_command( - "editor.action.peekDefinition", + peek.id(), viewer => viewer.definition_peek_available(), viewer => viewer.start_peek_definition(), [("F12", true, false)], - title="Peek Definition", - menu_keybinding_label=definition_menu_keybinding_label( - alt=true, - ctrl_cmd=false, - ), + title=peek.title(), + menu_keybinding_label=peek.keybinding_label().unwrap(), menu_placements=[ { menu_id: EditorContext, group: "navigation", order: 1.2 }, ], @@ -231,17 +168,8 @@ fn register_definition_contribution( } ///| -fn definition_menu_keybinding_label(alt~ : Bool, ctrl_cmd~ : Bool) -> String { - if @base_common.is_macintosh { - "\{(if alt { "\u{2325}" } else { "" })}\{(if ctrl_cmd { "\u{2318}" } else { "" })}F12" - } else { - "\{(if alt { "Alt+" } else { "" })}\{(if ctrl_cmd { "Ctrl+" } else { "" })}F12" - } -} - -///| -fn Viewer::definition_contribution( - self : Viewer, +fn CodeEditorWidget::definition_contribution( + self : CodeEditorWidget, ) -> DefinitionContributionState? { match self.contributions.lookup(definition_contribution_id) { Some(entry) => @@ -254,8 +182,11 @@ fn Viewer::definition_contribution( } ///| -fn Viewer::definition_action_available(self : Viewer) -> Bool { +fn CodeEditorWidget::definition_action_available( + self : CodeEditorWidget, +) -> Bool { guard !self.disposed else { return false } + guard self.definition_contribution() is Some(_) else { return false } match self.get_model() { Some(model) => self.services.languages.has_definition_provider(model) None => false @@ -263,38 +194,98 @@ fn Viewer::definition_action_available(self : Viewer) -> Bool { } ///| -fn Viewer::handle_markdown_definition_key_down( - self : Viewer, - key : String, - alt : Bool, - ctrl_cmd : Bool, - shift : Bool, -) -> Bool { - if !alt && - !ctrl_cmd && - ((key == "F10" && shift) || (key == "ContextMenu" && !shift)) { - return self.show_markdown_context_menu_from_keyboard() +fn CodeEditorWidget::symbol_navigation_anchor_at( + self : CodeEditorWidget, + position : @base_common.Position, +) -> @goto_symbol.SymbolNavigationAnchor? { + guard !self.disposed && self.get_model() is Some(model) else { return None } + let position = model.validate_position(position) + let range = match model.get_word_at_position(position) { + Some(word) => + @base_common.Range( + position.line_number, + word.start_column, + position.line_number, + word.end_column, + ) + None => + Range( + position.line_number, + position.column, + position.line_number, + position.column, + ) } - guard key == "F12" else { return false } - if shift && !alt && !ctrl_cmd && self.references_action_available() { - self.start_peek_references() - return true + Some( + @goto_symbol.SymbolNavigationAnchor::from_model( + model, + position, + range, + self.model_slot.generation, + ), + ) +} + +///| +fn CodeEditorWidget::symbol_navigation_anchor_is_current( + self : CodeEditorWidget, + anchor : @goto_symbol.SymbolNavigationAnchor, +) -> Bool { + guard !self.disposed && self.get_model() is Some(model) else { return false } + anchor.surface_generation() == self.model_slot.generation && + anchor.model().same(model) && + anchor.model_is_current() && + self.get_position() == Some(anchor.position()) +} + +///| +/// Surface-owned preparation shared by Definition and References actions. +/// Populated Peek sessions remain independently usable; only unresolved +/// queries are superseded by the new navigation intent. +fn CodeEditorWidget::prepare_symbol_navigation_intent( + self : CodeEditorWidget, +) -> Unit { + match self.references_contribution() { + Some(references) => + match references.controller.phase() { + PeekLoadingDefinitions(_) | PeekLoadingReferences(_) => + self.close_references_peek(restore_focus=false) + _ => () + } + None => () } - guard !shift else { return false } - if alt && !ctrl_cmd && self.definition_peek_available() { - self.start_peek_definition() - return true + match self.definition_contribution() { + Some(state) => state.reset_link_gesture() + None => () } - if !alt && self.definition_action_available() { - self.start_goto_definition() - return true + self.clear_definition_message() +} + +///| +fn CodeEditorWidget::open_symbol_navigation_peek( + self : CodeEditorWidget, + anchor : @goto_symbol.SymbolNavigationAnchor, + mode : @references.ReferencesPeekMode, + locations : Array[@language.Location], +) -> Bool { + guard self.symbol_navigation_anchor_is_current(anchor) else { return false } + match mode { + Definitions => { + guard self.definition_peek_available() else { return false } + self.open_definition_peek_with_locations(anchor.position(), locations) + true + } + References => { + guard self.references_peek_available() else { return false } + self.show_references(anchor.position(), locations) + true + } } - false } ///| -fn Viewer::hook_definition_contribution( - self : Viewer, +fn CodeEditorWidget::hook_definition_contribution( + self : CodeEditorWidget, ) -> Array[@base_common.Disposable] { [ self.on_did_change_model(_ => self.reset_definition_requests()), @@ -317,18 +308,17 @@ fn Viewer::hook_definition_contribution( /// Cursor movement invalidates an F12 action and a definition-link request. /// A Peek that is still resolving its definitions is anchored to that same /// source position and must close; once results are installed, ordinary -/// selection changes do not destroy the Viewer-owned Peek session. When an +/// selection changes do not destroy the CodeEditorWidget-owned Peek session. When an /// unresolved definition link lets a collapsed mousedown perform the ordinary /// cursor move, that event cancels only the preview request: the independent /// mouse-down line must survive until mouseup launches the fresh action. A /// non-empty drag selection resets the whole gesture and must never navigate. -fn Viewer::on_definition_cursor_selection_changed( - self : Viewer, +fn CodeEditorWidget::on_definition_cursor_selection_changed( + self : CodeEditorWidget, selection_is_empty : Bool, ) -> Unit { guard self.definition_contribution() is Some(state) else { return } - state.invalidate_open_feedback() - state.cancel_action() + state.navigation_action.cancel() if state.definition_mouse_down_line > 0 && selection_is_empty { state.cancel_link() } else { @@ -336,7 +326,7 @@ fn Viewer::on_definition_cursor_selection_changed( } match self.references_contribution() { Some(references) => - match references.phase { + match references.controller.phase() { PeekLoadingDefinitions(_) | PeekLoadingReferences(_) => self.close_references_peek(restore_focus=false) _ => () @@ -347,7 +337,7 @@ fn Viewer::on_definition_cursor_selection_changed( } ///| -fn Viewer::cancel_definition_link(self : Viewer) -> Unit { +fn CodeEditorWidget::cancel_definition_link(self : CodeEditorWidget) -> Unit { match self.definition_contribution() { Some(state) => state.cancel_link_gesture() None => () @@ -355,11 +345,10 @@ fn Viewer::cancel_definition_link(self : Viewer) -> Unit { } ///| -fn Viewer::reset_definition_requests(self : Viewer) -> Unit { - self.clear_markdown_context_menu_anchor() +fn CodeEditorWidget::reset_definition_requests(self : CodeEditorWidget) -> Unit { match self.definition_contribution() { Some(state) => { - state.cancel_action() + state.navigation_action.cancel() state.reset_link_gesture() self.close_references_peek(restore_focus=false) self.dispose_definition_message() @@ -369,8 +358,7 @@ fn Viewer::reset_definition_requests(self : Viewer) -> Unit { } ///| -fn Viewer::reset_definition_link(self : Viewer) -> Unit { - self.clear_markdown_context_menu_anchor() +fn CodeEditorWidget::reset_definition_link(self : CodeEditorWidget) -> Unit { match self.definition_contribution() { Some(state) => state.reset_link_gesture() None => () @@ -378,7 +366,7 @@ fn Viewer::reset_definition_link(self : Viewer) -> Unit { } ///| -fn Viewer::start_goto_definition(self : Viewer) -> Unit { +fn CodeEditorWidget::start_goto_definition(self : CodeEditorWidget) -> Unit { guard self.definition_command_position() is Some(position) else { return } self.start_goto_definition_at(position, mute_message=false) } @@ -387,93 +375,28 @@ fn Viewer::start_goto_definition(self : Viewer) -> Unit { /// Starts the ordinary Definition action at an already validated source /// position. Definition-link mouseup deliberately uses this entry instead of /// consuming the preview request's cached locations. -fn Viewer::start_goto_definition_at( - self : Viewer, +fn CodeEditorWidget::start_goto_definition_at( + self : CodeEditorWidget, position : @base_common.Position, mute_message~ : Bool, ) -> Unit { guard self.definition_contribution() is Some(state) else { return } - guard self.get_model() is Some(model) else { return } - let position = model.validate_position(position) - // An ordinary Definition action is a newer navigation intent than a Peek - // request that is still resolving its definition list. Retire only that - // loading shell: an already populated Peek remains independently usable. - match self.references_contribution() { - Some(references) => - match references.phase { - PeekLoadingDefinitions(_) | PeekLoadingReferences(_) => - self.close_references_peek(restore_focus=false) - _ => () - } - None => () + guard self.symbol_navigation_anchor_at(position) is Some(anchor) else { + return } - // A new navigation intent immediately supersedes feedback from any opener - // that is still awaiting its host, even before this provider request - // resolves far enough to call the next opener. - state.invalidate_open_feedback() - state.cancel_action() - self.clear_definition_message() - state.action_generation += 1 - let generation = state.action_generation - let source = @language.CancellationTokenSource() - state.action_source = Some(source) - let stamp : DefinitionActionStamp = { - generation, - model_identity: model.identity(), - model_generation: self.model_slot.generation, - model_version_id: model.get_version_id(), + guard state.definition_action.begin(anchor) is Some(generation) else { + return } - let offset = model.get_offset_at(position) let task = state.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, + state.definition_action.run_reserved( + anchor, + { open_to_side: false, open_in_peek: false, mute_message }, + generation, ) - if !self.definition_action_is_current(stamp, source) { - state.retire_action_source(source, cancel=true) - return - } - source.dispose() - state.action_source = None - let locations = @definition.normalize_definition_locations(locations) - self.dispatch_definition_locations(position, locations, mute_message~) }) } -///| -/// Applies Monaco's default definition result policy without asking providers -/// a second time: direct-open one result, or populate Peek for multiple -/// results when this outer mounted Viewer can host it. -async fn Viewer::dispatch_definition_locations( - self : Viewer, - source_position : @base_common.Position, - locations : Array[@language.Location], - mute_message~ : Bool, -) -> Unit noraise { - if locations.is_empty() { - if !mute_message { - self.show_definition_message( - self.definition_no_result_message(source_position), - position=source_position, - ) - } - return - } - if locations.length() > 1 && self.definition_peek_available() { - self.open_definition_peek_with_locations(source_position, locations) - return - } - self.open_definition_location( - locations[0], - Current, - message_position=source_position, - ) - |> ignore -} - ///| fn definition_link_modifier_is_active( ctrl : Bool, @@ -490,8 +413,8 @@ fn definition_link_modifier_is_active( } ///| -fn Viewer::definition_target_from_mouse_target( - self : Viewer, +fn CodeEditorWidget::definition_target_from_mouse_target( + self : CodeEditorWidget, target : @editor_browser.MouseTarget, ) -> @definition.DefinitionTargetFingerprint? { guard self.get_model() is Some(model) else { return None } @@ -518,42 +441,15 @@ fn Viewer::definition_target_from_mouse_target( } ///| -fn Viewer::definition_target_from_source_offset( - self : Viewer, - source_offset : Int, -) -> @definition.DefinitionTargetFingerprint? { - guard self.get_model() is Some(model) && !model.is_disposed() else { - return None - } - let position = model.get_position_at(source_offset) - guard model.get_word_at_position(position) is Some(word) else { return None } - Some( - @definition.DefinitionTargetFingerprint::from_model( - model, - self.model_slot.generation, - position, - word, - ), - ) -} - -///| -fn Viewer::definition_command_position(self : Viewer) -> @base_common.Position? { - if self.current_markdown_document() is None { - return self.get_position() - } - guard self.current_markdown_document() is Some(_) && - self.definition_contribution() is Some(state) && - state.last_pointer_target is Some(target) && - self.definition_link_target_is_current(target) else { - return None - } - Some(target.position()) +fn CodeEditorWidget::definition_command_position( + self : CodeEditorWidget, +) -> @base_common.Position? { + self.get_position() } ///| -fn Viewer::definition_link_target_is_current( - self : Viewer, +fn CodeEditorWidget::definition_link_target_is_current( + self : CodeEditorWidget, target : @definition.DefinitionTargetFingerprint, ) -> Bool { match self.get_model() { @@ -561,11 +457,7 @@ fn Viewer::definition_link_target_is_current( guard target.is_current(model, self.model_slot.generation) else { return false } - match self.current_markdown_document() { - Some(markdown) => - markdown.source_model_version() == model.get_version_id() - None => true - } + true } None => false } @@ -575,16 +467,15 @@ fn Viewer::definition_link_target_is_current( /// Commits a plain-click command anchor. Pointer hover alone must not cancel a /// Markdown request, but an actual click is the projection equivalent of a Code /// cursor change and invalidates the old action/loading Peek immediately. -fn Viewer::commit_plain_definition_pointer_target( - self : Viewer, +fn CodeEditorWidget::commit_plain_definition_pointer_target( + self : CodeEditorWidget, target : @definition.DefinitionTargetFingerprint?, ) -> Unit { guard self.definition_contribution() is Some(state) else { return } - state.invalidate_open_feedback() - state.cancel_action() + state.navigation_action.cancel() match self.references_contribution() { Some(references) => - match references.phase { + match references.controller.phase() { PeekLoadingDefinitions(_) | PeekLoadingReferences(_) => self.close_references_peek(restore_focus=false) _ => () @@ -601,8 +492,8 @@ fn Viewer::commit_plain_definition_pointer_target( } ///| -fn Viewer::start_definition_link_request( - self : Viewer, +fn CodeEditorWidget::start_definition_link_request( + self : CodeEditorWidget, target : @definition.DefinitionTargetFingerprint, ) -> Unit { guard self.definition_contribution() is Some(state) else { return } @@ -646,32 +537,23 @@ fn Viewer::start_definition_link_request( return } state.link_state = LinkArmed(stamp, locations) - match self.current_markdown_document() { - Some(markdown) => { - let source_range = model.snapshot.offset_range_of(target.word_range()) - state.markdown_link_elements = markdown.append_projected_range_spans( - source_range, "moonbit-viewer-markdown-definition-link", - ) - } - None => - state.link_decorations.set([ - { - range: target.word_range(), - options: ModelDecorationOptions( - "", - inline_class_name="moonbit-viewer-definition-link", - description="definition-link", - ), - }, - ]) - |> ignore - } + state.link_decorations.set([ + { + range: target.word_range(), + options: ModelDecorationOptions( + "", + inline_class_name="moonbit-viewer-definition-link", + description="definition-link", + ), + }, + ]) + |> ignore }) } ///| -fn Viewer::definition_link_request_is_current( - self : Viewer, +fn CodeEditorWidget::definition_link_request_is_current( + self : CodeEditorWidget, stamp : @definition.DefinitionRequestStamp, source : @language.CancellationTokenSource, ) -> Bool { @@ -695,8 +577,8 @@ fn Viewer::definition_link_request_is_current( } ///| -fn Viewer::on_definition_mouse_move( - self : Viewer, +fn CodeEditorWidget::on_definition_mouse_move( + self : CodeEditorWidget, event : @editor_browser.EditorMouseEvent, ) -> Unit { let target = self.definition_target_from_mouse_target(event.target) @@ -711,8 +593,8 @@ fn Viewer::on_definition_mouse_move( } ///| -fn Viewer::handle_definition_pointer_move( - self : Viewer, +fn CodeEditorWidget::handle_definition_pointer_move( + self : CodeEditorWidget, target : @definition.DefinitionTargetFingerprint?, ctrl : Bool, meta : Bool, @@ -749,8 +631,8 @@ fn definition_link_key_is_trigger(key : String) -> Bool { } ///| -fn Viewer::handle_definition_modifier_key( - self : Viewer, +fn CodeEditorWidget::handle_definition_modifier_key( + self : CodeEditorWidget, key : String, key_down : Bool, ctrl : Bool, @@ -791,8 +673,8 @@ fn Viewer::handle_definition_modifier_key( } ///| -fn Viewer::on_definition_mouse_up( - self : Viewer, +fn CodeEditorWidget::on_definition_mouse_up( + self : CodeEditorWidget, event : @editor_browser.EditorMouseEvent, ) -> Unit { let target = self.definition_target_from_mouse_target(event.target) @@ -809,8 +691,8 @@ fn Viewer::on_definition_mouse_up( } ///| -fn Viewer::handle_definition_pointer_up( - self : Viewer, +fn CodeEditorWidget::handle_definition_pointer_up( + self : CodeEditorWidget, target : @definition.DefinitionTargetFingerprint?, ctrl : Bool, meta : Bool, @@ -840,63 +722,17 @@ fn Viewer::handle_definition_pointer_up( } let position = target.position() state.reset_link_gesture() - if self.current_markdown_document() is None { - self.set_position(position, source="gotoDefinitionLink") - } + self.set_position(position, source="gotoDefinitionLink") self.start_goto_definition_at(position, mute_message=true) true } -///| -fn Viewer::definition_action_is_current( - self : Viewer, - stamp : DefinitionActionStamp, - source : @language.CancellationTokenSource, -) -> Bool { - guard !self.disposed && !source.token().is_cancellation_requested() else { - return false - } - guard self.definition_contribution() is Some(state) else { return false } - guard state.action_generation == stamp.generation 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 -} - -///| -async fn Viewer::open_definition_location( - self : Viewer, - location : @language.Location, - mode : @navigation_api.OpenLocationMode, - message_position? : @base_common.Position, -) -> Bool noraise { - guard !self.disposed else { return false } - guard self.definition_contribution() is Some(state) else { return false } - guard self.get_model() is Some(source_model) else { return false } - state.invalidate_open_feedback() - let stamp : DefinitionOpenStamp = { - generation: state.open_generation, - model_identity: source_model.identity(), - model_generation: self.model_slot.generation, - model_version_id: source_model.get_version_id(), - } - let accepted = self.open_peek_location(location, mode, "gotoDefinition") - if !accepted && self.definition_open_is_current(stamp) { - self.show_definition_message( - "Unable to open definition", - position?=message_position, - ) - } - accepted -} - ///| /// VS Code's `_openReference` target feedback shared by Definition and /// References navigation: retain the full target range for 350ms, and clear it -/// only if the Viewer still owns the exact model that received the decoration. -fn Viewer::show_navigation_target_highlight( - self : Viewer, +/// only if the CodeEditorWidget still owns the exact model that received the decoration. +fn CodeEditorWidget::show_navigation_target_highlight( + self : CodeEditorWidget, target_model : @model.TextModel, range : @base_common.Range, ) -> Unit { @@ -922,41 +758,8 @@ fn Viewer::show_navigation_target_highlight( } ///| -fn Viewer::definition_open_is_current( - self : Viewer, - stamp : DefinitionOpenStamp, -) -> Bool { - guard !self.disposed else { return false } - guard self.definition_contribution() is Some(state) && - !state.disposed && - state.open_generation == stamp.generation 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::definition_no_result_message( - self : Viewer, - position : @base_common.Position, -) -> String { - match self.get_model() { - Some(model) => - match model.get_word_at_position(model.validate_position(position)) { - Some(word) if !word.word.is_empty() => - "No definition found for '\{word.word}'" - _ => "No definition found" - } - None => "No definition found" - } -} - -///| -fn Viewer::show_definition_message( - self : Viewer, +fn CodeEditorWidget::show_definition_message( + self : CodeEditorWidget, message : String, position? : @base_common.Position, ) -> Unit { @@ -968,10 +771,7 @@ fn Viewer::show_definition_message( None => () } state.message_hide = None - guard self.current_markdown_document() is Some(_) || - self.current_code_browser_data() is Some(_) else { - return - } + guard self.current_code_browser_data() is Some(_) else { return } guard self.get_model() is Some(model) else { return } let position = model.validate_position( match position { @@ -991,31 +791,13 @@ fn Viewer::show_definition_message( widget } } - match self.current_markdown_document() { - Some(markdown) => { - markdown.overlay_mount().append_child(widget.get_dom_node().as_node()) - @base_browser.set_style_property( - widget.get_dom_node(), - "position", - "absolute", - ) - @base_browser.set_style_property(widget.get_dom_node(), "top", "12px") - @base_browser.set_style_property( - widget.get_dom_node(), - "right", - "12px", - ) - } - None => { - guard self.current_code_view() is Some(view) else { return } - if widget.get_dom_node().get_attribute("data-content-widget") is None { - view.content_widgets.add_widget(widget.content_widget_handle()) - } - @base_browser.remove_style_property(widget.get_dom_node(), "top") - @base_browser.remove_style_property(widget.get_dom_node(), "right") - self.reveal_position_in_center_if_outside_viewport(position) - } + guard self.current_code_view() is Some(view) else { return } + if widget.get_dom_node().get_attribute("data-content-widget") is None { + view.content_widgets.add_widget(widget.content_widget_handle()) } + @base_browser.remove_style_property(widget.get_dom_node(), "top") + @base_browser.remove_style_property(widget.get_dom_node(), "right") + self.reveal_position_in_center_if_outside_viewport(position) widget.set_message(message) widget.show_at(position) match self.current_code_view() { @@ -1053,7 +835,7 @@ fn Viewer::show_definition_message( } ///| -fn Viewer::clear_definition_message(self : Viewer) -> Unit { +fn CodeEditorWidget::clear_definition_message(self : CodeEditorWidget) -> Unit { match self.definition_contribution() { Some(state) => { state.last_message = None @@ -1083,7 +865,9 @@ fn Viewer::clear_definition_message(self : Viewer) -> Unit { } ///| -fn Viewer::dispose_definition_message(self : Viewer) -> Unit { +fn CodeEditorWidget::dispose_definition_message( + self : CodeEditorWidget, +) -> Unit { match self.definition_contribution() { Some(state) => { match state.message_hide { @@ -1106,7 +890,9 @@ fn Viewer::dispose_definition_message(self : Viewer) -> Unit { } ///| -fn Viewer::definition_message_for_test(self : Viewer) -> String? { +fn CodeEditorWidget::definition_message_for_test( + self : CodeEditorWidget, +) -> String? { match self.definition_contribution() { Some(state) => state.last_message None => None @@ -1114,8 +900,8 @@ fn Viewer::definition_message_for_test(self : Viewer) -> String? { } ///| -fn Viewer::set_definition_host_for_test( - self : Viewer, +fn CodeEditorWidget::set_definition_host_for_test( + self : CodeEditorWidget, launch : (async () -> Unit noraise) -> Unit, ) -> Unit { match self.definition_contribution() { @@ -1123,7 +909,7 @@ fn Viewer::set_definition_host_for_test( None => () } match self.references_contribution() { - Some(state) => state.launch_async_task = launch + Some(state) => state.controller.set_task_launcher(launch) None => () } } @@ -1131,8 +917,8 @@ fn Viewer::set_definition_host_for_test( ///| let _definition_test_seam_anchor : Unit = ignore( ( - Viewer::definition_message_for_test, - Viewer::set_definition_host_for_test, - Viewer::cancel_definition_link, + CodeEditorWidget::definition_message_for_test, + CodeEditorWidget::set_definition_host_for_test, + CodeEditorWidget::cancel_definition_link, ), ) diff --git a/editor/viewer/definition_link_host.mbt b/editor/internal/viewer/code_editor_widget/definition_link_host.mbt similarity index 90% rename from editor/viewer/definition_link_host.mbt rename to editor/internal/viewer/code_editor_widget/definition_link_host.mbt index 89d038d30..ce3f9c77a 100644 --- a/editor/viewer/definition_link_host.mbt +++ b/editor/internal/viewer/code_editor_widget/definition_link_host.mbt @@ -2,8 +2,8 @@ /// Narrow pre-selection seam used by the browser mouse controller. A press /// suppresses the ordinary cursor/selection dispatch only after the exact /// token has resolved to a live definition while Ctrl/Cmd was already held. -fn Viewer::before_definition_mouse_down_selection( - self : Viewer, +fn CodeEditorWidget::before_definition_mouse_down_selection( + self : CodeEditorWidget, event : @editor_browser.EditorDomMouseEvent, target : @editor_browser.MouseTarget, ) -> Bool { @@ -26,8 +26,8 @@ fn Viewer::before_definition_mouse_down_selection( } ///| -fn Viewer::before_definition_target_mouse_down( - self : Viewer, +fn CodeEditorWidget::before_definition_target_mouse_down( + self : CodeEditorWidget, target : @definition.DefinitionTargetFingerprint?, ) -> Bool { guard self.definition_contribution() is Some(state) else { return false } diff --git a/editor/viewer/definition_navigation_wbtest.mbt b/editor/internal/viewer/code_editor_widget/definition_navigation_wbtest.mbt similarity index 89% rename from editor/viewer/definition_navigation_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/definition_navigation_wbtest.mbt index 6b733f819..61957bde8 100644 --- a/editor/viewer/definition_navigation_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/definition_navigation_wbtest.mbt @@ -82,13 +82,13 @@ fn definition_navigation_services( locations : Array[@language.Location], opener? : @navigation_api.LocationOpenerHandle, resolver? : @navigation_api.TextModelResolverHandle, -) -> ViewerServices { +) -> CodeEditorServices { let languages = @languages.Languages() let provider = DefinitionNavigationTestProvider::{ locations, } languages.register_definition_provider(LanguageId("moonbit"), provider) |> ignore let log_service = @log.LogService() - ViewerServices( + CodeEditorServices( languages=languages.language_handle(log_service.log_handle()), location_opener?=opener, text_model_resolver?=resolver, @@ -97,27 +97,12 @@ fn definition_navigation_services( } ///| -test "cancellation callbacks cannot clobber replacement request owners" { +test "definition-link cancellation callbacks cannot clobber replacement owners" { let services = definition_navigation_services([]) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) guard viewer.definition_contribution() is Some(state) else { fail("definition contribution was not installed") } - let references = viewer.references_contribution().unwrap() - - let old_action = @language.CancellationTokenSource() - let new_action = @language.CancellationTokenSource() - old_action - .token() - .on_cancellation_requested(() => state.action_source = Some(new_action)) - |> ignore - state.action_source = Some(old_action) - state.cancel_action() - guard state.action_source is Some(current_action) else { - fail("action cancellation clobbered its replacement owner") - } - assert_true(physical_equal(current_action, new_action)) - let old_link = @language.CancellationTokenSource() let new_link = @language.CancellationTokenSource() old_link @@ -131,40 +116,8 @@ test "cancellation callbacks cannot clobber replacement request owners" { } assert_true(physical_equal(current_link, new_link)) - let old_preview = @language.CancellationTokenSource() - let new_preview = @language.CancellationTokenSource() - old_preview - .token() - .on_cancellation_requested(() => references.preview_source = Some(new_preview)) - |> ignore - references.preview_source = Some(old_preview) - viewer.cancel_definition_peek_preview_request() - guard references.preview_source is Some(current_preview) else { - fail("preview cancellation clobbered its replacement owner") - } - assert_true(physical_equal(current_preview, new_preview)) - - let old_definitions = @language.CancellationTokenSource() - let new_definitions = @language.CancellationTokenSource() - old_definitions - .token() - .on_cancellation_requested(() => state.peek_source = Some(new_definitions)) - |> ignore - state.peek_source = Some(old_definitions) - viewer.close_definition_peek(restore_focus=false) - guard state.peek_source is Some(current_definitions) else { - fail("Peek cancellation clobbered its replacement owner") - } - assert_true(physical_equal(current_definitions, new_definitions)) - - new_action.dispose(cancel=true) new_link.dispose(cancel=true) - new_preview.dispose(cancel=true) - new_definitions.dispose(cancel=true) - state.action_source = None state.link_source = None - references.preview_source = None - state.peek_source = None viewer.dispose() services.dispose() } @@ -182,7 +135,7 @@ async test "F12 reveals a same-resource definition without calling the opener" { true }) let services = definition_navigation_services([location], opener~) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -229,7 +182,7 @@ async test "headless multiple definitions retain provider-first fallback" { ], opener~, ) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -255,7 +208,7 @@ async test "F12 sends a cross-resource request and preserves rejection feedback" false }) let services = definition_navigation_services([location], opener~) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -301,7 +254,7 @@ async test "a superseded definition opener failure cannot overwrite a newer navi } }) let services = definition_navigation_services([location], opener~) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -345,7 +298,7 @@ async test "cursor movement suppresses late definition opener failure" { false }) let services = definition_navigation_services([location], opener~) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -377,7 +330,7 @@ async test "empty and stale definition actions cannot navigate" { true }) let services = definition_navigation_services([location], opener~) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -389,20 +342,12 @@ async test "empty and stale definition actions cannot navigate" { viewer.set_position(Position(1, 5)) viewer.start_goto_definition() - guard viewer.definition_contribution() is Some(action_state) else { - fail("missing definition contribution") - } - guard action_state.action_source is Some(action_source) else { - fail("F12 did not retain its cancellation source") - } viewer.set_position(Position(1, 6)) - assert_true(action_source.token().is_cancellation_requested()) - assert_true(action_state.action_source is None) tasks.run_next() assert_eq(opener_calls.val, 0) let empty_services = definition_navigation_services([], opener~) - let empty_viewer = Viewer(services=empty_services) + let empty_viewer = CodeEditorWidget(services=empty_services) let empty_tasks = ManualDefinitionTasks() empty_viewer.set_definition_host_for_test(task => empty_tasks.schedule(task)) let empty_model = definition_navigation_test_model("file:///empty.mbt") @@ -426,7 +371,7 @@ async test "empty and stale definition actions cannot navigate" { async test "empty definition-link result is cached silently for one gesture" { let model = definition_navigation_test_model("file:///source.mbt") let services = definition_navigation_services([]) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -505,7 +450,7 @@ async test "definition-link arms only a fresh token and stale content clears it" range: Range(3, 2, 3, 8), } let services = definition_navigation_services([location]) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -588,12 +533,12 @@ async test "definition-link caches by word and executes a fresh Definition actio true }) let log_service = @log.LogService() - let services = ViewerServices( + let services = CodeEditorServices( languages=languages.language_handle(log_service.log_handle()), location_opener=opener, log_service=log_service.log_handle(), ) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -681,7 +626,7 @@ async test "definition-link click does not wait for preview resolution" { true }) let services = definition_navigation_services([location], opener~) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -744,7 +689,7 @@ async test "drag selection cancels an unresolved definition-link click" { true }) let services = definition_navigation_services([location], opener~) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -788,7 +733,7 @@ async test "drag selection cancels an unresolved definition-link click" { test "plain definition pointer-up preserves the keyboard command anchor" { let model = definition_navigation_test_model("file:///source.mbt") let services = definition_navigation_services([]) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) viewer.set_model(Some(model)) let position = @base_common.Position(1, 14) let target = @definition.DefinitionTargetFingerprint::from_model( @@ -837,7 +782,7 @@ async test "plain definition click cancels the previous pointer-anchored action" [{ uri: target_uri, range: Range(3, 2, 3, 6) }], opener~, ) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -851,12 +796,8 @@ async test "plain definition click cancels the previous pointer-anchored action" ) viewer.start_goto_definition_at(first_position, mute_message=false) - guard viewer.definition_contribution() is Some(state) && - state.action_source is Some(first_source) else { - fail("first definition action was not pending") - } + let state = viewer.definition_contribution().unwrap() viewer.commit_plain_definition_pointer_target(Some(second_target)) - assert_true(first_source.token().is_cancellation_requested()) assert_eq(state.last_pointer_target, Some(second_target)) tasks.run_next() assert_eq(opener_calls.val, 0) @@ -871,7 +812,7 @@ async test "plain definition click cancels the previous pointer-anchored action" test "non-left definition mouseup does not replace the command anchor" { let model = definition_navigation_test_model("file:///source.mbt") let services = definition_navigation_services([]) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) let tasks = ManualDefinitionTasks() viewer.set_definition_host_for_test(task => tasks.schedule(task)) viewer.set_model(Some(model)) @@ -890,10 +831,7 @@ test "non-left definition mouseup does not replace the command anchor" { model.get_word_at_position(second_position).unwrap(), ) viewer.start_goto_definition_at(first_position, mute_message=false) - guard viewer.definition_contribution() is Some(state) && - state.action_source is Some(source) else { - fail("definition action was not pending") - } + let state = viewer.definition_contribution().unwrap() state.last_pointer_target = Some(first_target) assert_false( @@ -907,7 +845,6 @@ test "non-left definition mouseup does not replace the command anchor" { detail=1, ), ) - assert_false(source.token().is_cancellation_requested()) assert_eq(state.last_pointer_target, Some(first_target)) viewer.dispose() @@ -919,7 +856,7 @@ test "non-left definition mouseup does not replace the command anchor" { test "definition-link modifier edges cancel unrelated CtrlCmd chords" { let model = definition_navigation_test_model("file:///source.mbt") let services = definition_navigation_services([]) - let viewer = Viewer(services~) + let viewer = CodeEditorWidget(services~) viewer.set_model(Some(model)) let position = @base_common.Position(1, 14) let target = @definition.DefinitionTargetFingerprint::from_model( diff --git a/editor/internal/viewer/code_editor_widget/definition_peek_host.mbt b/editor/internal/viewer/code_editor_widget/definition_peek_host.mbt new file mode 100644 index 000000000..264149e58 --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/definition_peek_host.mbt @@ -0,0 +1,449 @@ +///| +priv struct CodeReferencesPeekMountOwner { + viewer : CodeEditorWidget + view : @view.View + zone_id : String + overlay : @editor_browser.OverlayWidget + mut disposed : Bool +} + +///| +fn CodeReferencesPeekMountOwner::is_current( + self : CodeReferencesPeekMountOwner, +) -> Bool { + !self.disposed && + !self.viewer.disposed && + self.viewer.current_code_browser_data() is Some(browser) && + physical_equal(browser.view, self.view) +} + +///| +fn CodeReferencesPeekMountOwner::dispose( + self : CodeReferencesPeekMountOwner, +) -> Unit { + if self.disposed { + return + } + self.disposed = true + self.viewer.remove_overlay_widget_if_current(self.overlay) |> ignore + self.view.change_view_zones(accessor => accessor.remove_zone(self.zone_id)) + if !self.viewer.disposed { + self.viewer.schedule_render() + } +} + +///| +priv struct CodeReferencesPeekPreviewOwner { + viewer : CodeEditorWidget + decorations : EditorDecorationsCollection + resize_observer : @base_common.Disposable? + layout_frame : @base_common.Disposable + mut disposed : Bool +} + +///| +fn CodeReferencesPeekPreviewOwner::contains_focus( + self : CodeReferencesPeekPreviewOwner, +) -> Bool { + guard !self.disposed && self.viewer.get_dom_node() is Some(root) else { + return false + } + @base_browser.element_contains_active_element(root) +} + +///| +fn CodeReferencesPeekPreviewOwner::dispose( + self : CodeReferencesPeekPreviewOwner, +) -> Unit { + if self.disposed { + return + } + self.disposed = true + match self.resize_observer { + Some(observer) => observer.dispose() + None => () + } + self.layout_frame.dispose() + self.viewer.set_model(None) + self.viewer.dispose() + self.decorations.clear() +} + +///| +/// CodeEditor preview creation injected into the shared ReferencesController. +/// Markdown uses the same factory while retaining an independent controller. +pub fn code_editor_references_preview_factory( + services : CodeEditorServices, + options : CodeEditorOptions, +) -> @references_browser.ReferencesPeekPreviewFactory { + { + create: (host, model, selected_range, match_ranges, mode) => { + guard !model.is_disposed() else { return None } + let preview = CodeEditorWidget( + services~, + options~, + initial_container=host, + contributions=CodeEditorContributionDescriptors::peek_preview(), + ) + preview.attach(host) + preview.set_model(Some(model)) + let decorations : Array[@model.ModelDeltaDecoration] = [] + for range in match_ranges { + decorations.push({ + range: model.validate_range(range), + options: ModelDecorationOptions( + "moonbit-viewer-references-peek-reference-match", + description="peek-reference-match", + ), + }) + } + let selected_range = model.validate_range(selected_range) + decorations.push({ + range: selected_range, + options: ModelDecorationOptions( + "moonbit-viewer-references-peek-reference-match-selected", + description="peek-reference-match-selected", + ), + }) + let collection = preview.create_decorations_collection(decorations~) + let start = selected_range.get_start_position() + preview.set_position( + start, + source=if mode == Definitions { + "peekDefinition" + } else { + "peekReferences" + }, + ) + preview.reveal_range_in_center_if_outside_viewport( + Range(start.line_number, start.column, start.line_number, start.column), + ) + preview.handle_initialized() + let owner_slot : Ref[CodeReferencesPeekPreviewOwner?] = Ref(None) + let resize_observer = @base_browser.observe_element_resize(host, () => { + match owner_slot.val { + Some(owner) if !owner.disposed => preview.layout() + _ => () + } + }) + let layout_frame = @base_browser.schedule_at_next_animation_frame(() => { + match owner_slot.val { + Some(owner) if !owner.disposed => preview.layout() + _ => () + } + }) + let owner : CodeReferencesPeekPreviewOwner = { + viewer: preview, + decorations: collection, + resize_observer, + layout_frame, + disposed: false, + } + owner_slot.val = Some(owner) + Some( + ReferencesPeekPreview( + contains_focus=() => owner.contains_focus(), + focus=() => preview.focus(), + dispose=() => owner.dispose(), + ), + ) + }, + } +} + +///| +fn CodeEditorWidget::references_peek_height_in_lines( + self : CodeEditorWidget, +) -> 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 { + requested + } else { + requested.min((snapshot.height / line_height * 0.8).max(12.0)) + } +} + +///| +fn CodeEditorWidget::mount_code_references_peek( + self : CodeEditorWidget, + root : @rdom.Element, + anchor : @goto_symbol.SymbolNavigationAnchor, +) -> @references_browser.ReferencesPeekMount? { + guard self.symbol_navigation_anchor_is_current(anchor) && + self.current_code_browser_data() is Some(browser) else { + return None + } + let zone_node = @rdom.document().create_element("div") + zone_node.set_attribute("class", "moonbit-viewer-references-peek-zone") + let layout = self.get_layout_info() + @base_browser.set_style_property(root, "position", "absolute") + @base_browser.set_style_property(root, "top", "-1000000px") + @base_browser.set_style_property(root, "left", "0") + @base_browser.set_style_property( + root, + "right", + "\{layout.vertical_scrollbar_width.to_string()}px", + ) + @base_browser.set_style_property(root, "width", "auto") + let position = anchor.position() + let zone = @editor_browser.ViewZone( + position.line_number, + zone_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(root, "top", "\{top.to_string()}px") + }, + on_computed_height=height => { + @base_browser.set_style_property( + root, + "height", + "\{height.to_string()}px", + ) + }, + ) + let zone_id = Ref("") + self.change_view_zones(accessor => zone_id.val = accessor.add_zone(zone)) + if zone_id.val == "" { + return None + } + let overlay = overlay_widget( + "moonbit.viewer.referencesPeek.\{self.get_id()}", + root, + ) + self.add_overlay_widget(overlay) + let owner : CodeReferencesPeekMountOwner = { + viewer: self, + view: browser.view, + zone_id: zone_id.val, + overlay, + disposed: false, + } + if !owner.is_current() || !self.symbol_navigation_anchor_is_current(anchor) { + owner.dispose() + return None + } + Some( + ReferencesPeekMount(is_current=() => owner.is_current(), dispose=() => { + owner.dispose() + }), + ) +} + +///| +fn CodeEditorWidget::reveal_references_peek_anchor( + self : CodeEditorWidget, + anchor : @goto_symbol.SymbolNavigationAnchor, +) -> Unit { + guard self.symbol_navigation_anchor_is_current(anchor) else { return } + let line_number = anchor.position().line_number + let end_line_number = (line_number + 1).min(anchor.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 CodeEditorWidget::create_references_controller( + self : CodeEditorWidget, +) -> @references_browser.ReferencesController { + ReferencesController( + { + is_anchor_current: anchor => { + self.symbol_navigation_anchor_is_current(anchor) + }, + mount: (root, anchor) => self.mount_code_references_peek(root, anchor), + reveal_anchor: anchor => self.reveal_references_peek_anchor(anchor), + restore_source_focus: () => self.focus(), + open_location: (location, mode, source) => { + self.open_peek_location(location, mode, source) + }, + show_message: (anchor, message) => { + self.show_definition_message(message, position=anchor.position()) + }, + }, + code_editor_references_preview_factory(self.services, self.get_options()), + resolver?=self.services.text_model_resolver, + launch_async_task, + ) +} + +///| +fn CodeEditorWidget::definition_peek_available(self : CodeEditorWidget) -> Bool { + self.references_peek_available() && + self.definition_action_available() && + self.get_model() is Some(_) +} + +///| +fn CodeEditorWidget::references_action_available( + self : CodeEditorWidget, +) -> 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 CodeEditorWidget::references_peek_available(self : CodeEditorWidget) -> Bool { + !self.disposed && + self.references_contribution() is Some(_) && + self.get_model() is Some(_) && + self.current_code_browser_data() is Some(_) +} + +///| +fn CodeEditorWidget::definition_peek_contains_focus( + self : CodeEditorWidget, +) -> Bool { + match self.references_contribution() { + Some(state) => state.controller.contains_focus() + None => false + } +} + +///| +fn CodeEditorWidget::start_peek_definition(self : CodeEditorWidget) -> Unit { + guard self.definition_peek_available() else { return } + guard self.definition_contribution() is Some(definition) else { return } + guard self.definition_command_position() is Some(position) && + self.symbol_navigation_anchor_at(position) is Some(anchor) && + definition.definition_action.begin(anchor) is Some(generation) else { + return + } + let task = definition.launch_async_task + task(async fn() noraise { + definition.definition_action.run_reserved( + anchor, + @goto_symbol.SymbolNavigationActionConfig::peek(), + generation, + ) + }) +} + +///| +fn CodeEditorWidget::start_goto_references(self : CodeEditorWidget) -> Unit { + self.start_references_action( + @goto_symbol.SymbolNavigationActionConfig::goto(), + ) +} + +///| +fn CodeEditorWidget::start_peek_references(self : CodeEditorWidget) -> Unit { + self.start_references_action( + @goto_symbol.SymbolNavigationActionConfig::peek(), + ) +} + +///| +fn CodeEditorWidget::start_references_action( + self : CodeEditorWidget, + config : @goto_symbol.SymbolNavigationActionConfig, +) -> Unit { + guard self.references_action_available() else { return } + guard self.definition_contribution() is Some(definition) else { return } + guard self.definition_command_position() is Some(position) && + self.symbol_navigation_anchor_at(position) is Some(anchor) && + definition.references_action.begin(anchor) is Some(generation) else { + return + } + let task = definition.launch_async_task + task(async fn() noraise { + definition.references_action.run_reserved(anchor, config, generation) + }) +} + +///| +fn CodeEditorWidget::open_definition_peek_with_locations( + self : CodeEditorWidget, + position : @base_common.Position, + locations : Array[@language.Location], +) -> Unit { + guard self.symbol_navigation_anchor_at(position) is Some(anchor) && + self.references_contribution() is Some(state) else { + return + } + state.controller.toggle_locations(anchor, Definitions, locations) |> ignore +} + +///| +pub fn CodeEditorWidget::show_references( + self : CodeEditorWidget, + position : @base_common.Position, + locations : Array[@language.Location], +) -> Unit { + guard self.symbol_navigation_anchor_at(position) is Some(anchor) && + self.references_contribution() is Some(state) else { + return + } + state.controller.toggle_locations(anchor, References, locations) |> ignore +} + +///| +async fn CodeEditorWidget::open_peek_location( + self : CodeEditorWidget, + location : @language.Location, + mode : @navigation_api.OpenLocationMode, + source : String, +) -> Bool noraise { + guard !self.disposed && 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 CodeEditorWidget::close_references_peek( + self : CodeEditorWidget, + restore_focus~ : Bool, +) -> Unit { + match self.references_contribution() { + Some(state) => state.controller.close(restore_focus~) + None => () + } +} + +///| +fn CodeEditorWidget::close_definition_peek( + self : CodeEditorWidget, + restore_focus~ : Bool, +) -> Unit { + self.close_references_peek(restore_focus~) +} + +///| +fn CodeEditorWidget::definition_peek_phase_for_test( + self : CodeEditorWidget, +) -> @references.ReferencesPeekPhase { + match self.references_contribution() { + Some(state) => state.controller.phase() + None => PeekClosed + } +} + +///| +let _definition_peek_test_seam_anchor : Unit = ignore( + CodeEditorWidget::definition_peek_phase_for_test, +) diff --git a/editor/internal/viewer/code_editor_widget/diff_geometry_primitives.mbt b/editor/internal/viewer/code_editor_widget/diff_geometry_primitives.mbt new file mode 100644 index 000000000..6e8295a1e --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/diff_geometry_primitives.mbt @@ -0,0 +1,462 @@ +// Kernel-owned primitives used by coordinators that align multiple code +// editors. Every exported value is a stable data contract; the browser view, +// controller, and configuration objects remain package-private. + +///| +/// Exact rendered height of one model line after projection. A wrapped line is +/// taller than the base line height; a hidden line is exactly zero. +pub(all) struct CodeEditorModelLineHeight { + line_number : Int + height_in_px : Double +} derive(Eq, Debug) + +///| +/// Actual non-owned zone geometry in model coordinates. +pub(all) struct CodeEditorForeignZoneHeight { + after_line_number : Int + height_in_px : Double +} derive(Eq, Debug) + +///| +/// Scroll and content facts captured in the same geometry read. Content and +/// scroll height exclude every zone id supplied to `geometry_snapshot`. +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) + +///| +/// Immutable code-editor geometry for one model version and layout read. +/// A higher-level diff package adapts this value to its own alignment engine; +/// the kernel does not depend on that package. +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::model_version( + self : CodeEditorGeometrySnapshot, +) -> Int { + self.model_version +} + +///| +pub fn CodeEditorGeometrySnapshot::line_height( + self : CodeEditorGeometrySnapshot, +) -> Double { + self.line_height +} + +///| +pub fn CodeEditorGeometrySnapshot::model_line_heights( + self : CodeEditorGeometrySnapshot, +) -> Array[CodeEditorModelLineHeight] { + self.model_line_heights.copy() +} + +///| +pub fn CodeEditorGeometrySnapshot::foreign_zone_heights( + self : CodeEditorGeometrySnapshot, +) -> Array[CodeEditorForeignZoneHeight] { + self.foreign_zone_heights.copy() +} + +///| +pub fn CodeEditorGeometrySnapshot::line_max_columns( + self : CodeEditorGeometrySnapshot, +) -> Array[Int] { + self.line_max_columns.copy() +} + +///| +pub fn CodeEditorGeometrySnapshot::scroll_content( + self : CodeEditorGeometrySnapshot, +) -> CodeEditorScrollContentFacts { + self.scroll_content +} + +///| +fn code_editor_foreign_zone_geometry( + layout_infos : Array[(String, Int, Double)], + excluded_zone_ids : Array[String], +) -> (Array[CodeEditorForeignZoneHeight], Double) { + let foreign : Array[CodeEditorForeignZoneHeight] = [] + let mut excluded_height = 0.0 + for info in layout_infos { + let (id, after_line_number, raw_height) = info + let height_in_px = raw_height.max(0.0) + if excluded_zone_ids.contains(id) { + excluded_height += height_in_px + } else if height_in_px > 0.0 { + foreign.push({ after_line_number, height_in_px }) + } + } + (foreign, excluded_height) +} + +///| +/// Captures exact model-axis geometry. Projection visibility makes hidden +/// lines zero-height; the projected start/end positions count every wrapped +/// view line without consulting rendered rows. Zone layout infos already use +/// model anchors, including anchors after hidden source ranges. +pub fn CodeEditorWidget::geometry_snapshot( + self : CodeEditorWidget, + excluded_zone_ids : Array[String], +) -> CodeEditorGeometrySnapshot? { + if self.disposed { + return None + } + guard (self.get_model(), self.get_view_model()) + is (Some(model), Some(view_model)) else { + return None + } + let line_height = self.configuration.snapshot().font_info.line_height.to_double() + let converter = view_model.coordinates_converter() + let model_line_heights : Array[CodeEditorModelLineHeight] = [] + let line_max_columns : Array[Int] = [] + for line_number in 1..<=model.get_line_count() { + let max_column = model.get_line_max_column(line_number) + line_max_columns.push(max_column) + let height_in_px = if converter.model_position_is_visible( + Position(line_number, 1), + ) { + let start = converter.model_position_to_view_position( + Position(line_number, 1), + ) + let end = converter.model_position_to_view_position( + Position(line_number, max_column), + ) + (end.line_number - start.line_number + 1).max(1).to_double() * line_height + } else { + 0.0 + } + model_line_heights.push({ line_number, height_in_px }) + } + + let layout_infos = match self.current_code_browser_data() { + Some(browser) => browser.view.view_zone_layout_infos() + None => [] + } + let (foreign_zone_heights, excluded_zone_height) = code_editor_foreign_zone_geometry( + layout_infos, excluded_zone_ids, + ) + let layout = view_model.view_layout + let position = layout.position() + let dimensions = layout.dimensions() + let content_height = (layout.get_content_height() - excluded_zone_height).max( + 0.0, + ) + Some({ + model_version: model.get_version_id(), + line_height, + model_line_heights, + foreign_zone_heights, + line_max_columns, + scroll_content: { + scroll_top: position.scroll_top, + scroll_left: position.scroll_left, + viewport_width: dimensions.width, + viewport_height: dimensions.height, + content_width: layout.get_content_width(), + content_height, + scroll_width: dimensions.scroll_width, + scroll_height: dimensions.height.max(content_height), + }, + }) +} + +///| +/// One managed zone replacement specification. The caller owns the element; +/// the code editor owns only its registration for the returned id's lifetime. +pub(all) struct ManagedCodeEditorViewZoneSpec { + after_line_number : Int + height_in_px : Double + dom_node : @rdom.Element + show_in_hidden_areas : Bool + margin_dom_node : @rdom.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, @editor_browser.ViewZone) -> Unit)? +} + +///| +pub fn ManagedCodeEditorViewZoneSpec::ManagedCodeEditorViewZoneSpec( + after_line_number : Int, + height_in_px : Double, + dom_node : @rdom.Element, + show_in_hidden_areas? : Bool = true, + margin_dom_node? : @rdom.Element, + min_width_in_px? : Double, + ordinal? : Int, + suppress_mouse_down? : Bool = true, + on_dom_node_top? : (Double) -> Unit raise, + on_dom_node_layout? : (Bool, Double) -> Unit raise, + on_zone_registered? : (String, @editor_browser.ViewZone) -> Unit, +) -> ManagedCodeEditorViewZoneSpec { + { + after_line_number, + height_in_px, + dom_node, + show_in_hidden_areas, + margin_dom_node, + min_width_in_px, + ordinal, + suppress_mouse_down, + on_dom_node_top, + on_dom_node_layout, + on_zone_registered, + } +} + +///| +fn apply_managed_view_zones_transaction( + previous_ids : Array[String], + specs : Array[ManagedCodeEditorViewZoneSpec], + generation : Int, + transact : ((@editor_browser.ViewZoneChangeAccessor) -> Unit raise) -> Unit, +) -> Array[String] { + let ids : Array[String] = [] + transact(accessor => { + for id in previous_ids { + // Width and removal are committed by one LinesLayout batch. The layout + // call marks horizontal extent dirty; removing the same whitespace in + // this transaction means no per-zone teardown relayout is required. + accessor.layout_zone(id) + accessor.remove_zone(id) + } + for spec in specs { + if spec.height_in_px <= 0.0 { + continue + } + spec.dom_node.set_attribute( + "data-managed-view-zone-generation", + generation.to_string(), + ) + match spec.margin_dom_node { + Some(margin_dom_node) => + margin_dom_node.set_attribute( + "data-managed-view-zone-generation", + generation.to_string(), + ) + None => () + } + let zone = view_zone( + spec.after_line_number, + spec.dom_node, + show_in_hidden_areas=spec.show_in_hidden_areas, + ordinal?=spec.ordinal, + suppress_mouse_down=spec.suppress_mouse_down, + height_in_px=spec.height_in_px, + min_width_in_px?=spec.min_width_in_px, + margin_dom_node?=spec.margin_dom_node, + on_dom_node_top?=spec.on_dom_node_top, + on_dom_node_layout?=spec.on_dom_node_layout, + ) + let id = accessor.add_zone(zone) + ids.push(id) + match spec.on_zone_registered { + Some(on_zone_registered) => on_zone_registered(id, zone) + None => () + } + } + }) + ids +} + +///| +/// Replaces one generation of managed zones in exactly one ViewZone +/// transaction. With no attached browser surface, no callback runs and the +/// returned ownership set is empty. +pub fn CodeEditorWidget::apply_managed_view_zones( + self : CodeEditorWidget, + previous_ids : Array[String], + specs : Array[ManagedCodeEditorViewZoneSpec], + generation : Int, +) -> Array[String] { + if self.disposed || self.current_code_browser_data() is None { + return [] + } + apply_managed_view_zones_transaction(previous_ids, specs, generation, update => { + self.change_view_zones(update) + }) +} + +///| +/// A model-relative viewport anchor. Keeping the model identity private makes +/// restoration a no-op after a model swap without exposing identity internals +/// to the coordinator. +pub struct StableCodeEditorViewportState { + model : @model.TextModel + anchor_line_number : Int + anchor_viewport_offset : Double + scroll_left : Double +} + +///| +pub fn StableCodeEditorViewportState::anchor_line_number( + self : StableCodeEditorViewportState, +) -> Int { + self.anchor_line_number +} + +///| +pub fn StableCodeEditorViewportState::anchor_viewport_offset( + self : StableCodeEditorViewportState, +) -> Double { + self.anchor_viewport_offset +} + +///| +pub fn StableCodeEditorViewportState::scroll_left( + self : StableCodeEditorViewportState, +) -> Double { + self.scroll_left +} + +///| +/// Captures the model line intersecting the current scroll top and that line's +/// viewport-relative top. A wrapped continuation stays anchored to its model +/// line, so a later zone replacement cannot turn view-line coordinates stale. +pub fn CodeEditorWidget::capture_stable_viewport_state( + self : CodeEditorWidget, +) -> StableCodeEditorViewportState? { + if self.disposed { + return None + } + guard (self.get_model(), self.get_view_model()) + is (Some(model), Some(view_model)) else { + return None + } + let layout = view_model.view_layout + let position = layout.position() + let anchor_view_line = layout.line_at_vertical_offset(position.scroll_top) + let anchor_model_position = view_model + .coordinates_converter() + .view_position_to_model_position(Position(anchor_view_line, 1)) + let anchor_line_number = anchor_model_position.line_number + let anchor_top = self.get_top_for_line_number(anchor_line_number) + if anchor_top < 0.0 { + return None + } + Some({ + model, + anchor_line_number, + anchor_viewport_offset: anchor_top - position.scroll_top, + scroll_left: position.scroll_left, + }) +} + +///| +/// Restores a captured anchor synchronously against the current layout. The +/// caller invokes this immediately after its managed-zone transaction; no +/// timer or frame correction belongs to this primitive. +pub fn CodeEditorWidget::restore_stable_viewport_state( + self : CodeEditorWidget, + state : StableCodeEditorViewportState, +) -> Bool { + if self.disposed { + return false + } + guard self.get_model() is Some(model) && model.same(state.model) else { + return false + } + let anchor_line_number = state.anchor_line_number.clamp( + min=1, + max=model.get_line_count(), + ) + let anchor_top = self.get_top_for_line_number(anchor_line_number) + if anchor_top < 0.0 { + return false + } + self.set_active_scroll_position( + scroll_top=(anchor_top - state.anchor_viewport_offset).max(0.0), + scroll_left=state.scroll_left, + ) + self.stabilize_active_scroll() + true +} + +///| +/// Returns the content-space top of one editor-managed ViewZone without +/// exposing the browser View or mutable ViewZone registry. Whitespace order is +/// `(afterViewLineNumber, ordinal)`; the top of one item is therefore the line +/// after its anchor minus this item and every later item at that same anchor. +fn CodeEditorWidget::managed_view_zone_top( + self : CodeEditorWidget, + zone_id : String, +) -> Double? { + if self.disposed { + return None + } + guard self.get_view_model() is Some(view_model) else { return None } + let whitespaces = view_model.get_whitespaces() + let mut target_index = -1 + for index in 0.. after_view_line_number { + break + } + target_and_later_height += whitespace.height.to_double() + } + Some( + (view_model.view_layout.vertical_offset_for_line(after_view_line_number + 1) - + target_and_later_height).max(0.0), + ) +} + +///| +/// Reveals the beginning of an installed managed ViewZone synchronously. This +/// is the diff-navigation primitive for deletion-only Inline hunks; callers do +/// not need a raw View, DOM geometry probe, timer, or animation-frame retry. +pub fn CodeEditorWidget::reveal_managed_view_zone_top( + self : CodeEditorWidget, + zone_id : String, +) -> Bool { + guard self.managed_view_zone_top(zone_id) is Some(top) else { return false } + self.set_active_scroll_position(scroll_top=top) + self.stabilize_active_scroll() + true +} + +///| +pub extend CodeEditorForeignZoneHeight with Debug::{to_repr} + +///| +pub extend CodeEditorForeignZoneHeight with Eq::{equal, not_equal} + +///| +pub extend CodeEditorModelLineHeight with Debug::{to_repr} + +///| +pub extend CodeEditorModelLineHeight with Eq::{equal, not_equal} + +///| +pub extend CodeEditorScrollContentFacts with Debug::{to_repr} + +///| +pub extend CodeEditorScrollContentFacts with Eq::{equal, not_equal} diff --git a/editor/internal/viewer/code_editor_widget/diff_geometry_primitives_wbtest.mbt b/editor/internal/viewer/code_editor_widget/diff_geometry_primitives_wbtest.mbt new file mode 100644 index 000000000..dd4777c33 --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/diff_geometry_primitives_wbtest.mbt @@ -0,0 +1,250 @@ +///| +extern "js" fn managed_view_zone_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; + #| }, + #| removeAttribute(name) { + #| this.attributes.delete(String(name)); + #| }, + #| }) + +///| +test "diff geometry snapshot follows wrap, hidden lines, and model columns" { + let options = CodeEditorOptions(font_size=12, line_height=18, soft_wrap=true) + with_test_viewer( + ( + #|abcdefghij + #|hidden + #|xyz + ), + viewer => { + viewer.test_set_soft_wrap_column(6) + viewer.set_hidden_areas([Range(2, 1, 2, 1)], source="diff-geometry") + let snapshot = viewer.geometry_snapshot([]).unwrap() + assert_eq(snapshot.line_height(), 18.0) + let heights = snapshot.model_line_heights() + assert_eq(heights.length(), 3) + assert_eq(heights[0], { line_number: 1, height_in_px: 36.0 }) + assert_eq(heights[1], { line_number: 2, height_in_px: 0.0 }) + assert_eq(heights[2], { line_number: 3, height_in_px: 18.0 }) + assert_true(snapshot.line_max_columns() == [11, 7, 4]) + assert_eq(snapshot.foreign_zone_heights().length(), 0) + assert_eq(snapshot.scroll_content().content_height, 54.0) + }, + options~, + ) +} + +///| +test "diff geometry snapshot includes one-read scroll and content facts" { + with_test_viewer(long_document(200), viewer => { + viewer.test_set_viewport(width=800.0, height=400.0) + viewer.set_scroll_position(scroll_top=720.0, scroll_left=0.0) + let snapshot = viewer.geometry_snapshot([]).unwrap() + let facts = snapshot.scroll_content() + assert_eq(facts.scroll_top, 720.0) + assert_eq(facts.scroll_left, 0.0) + assert_eq(facts.viewport_width, 800.0) + assert_eq(facts.viewport_height, 400.0) + assert_eq(facts.content_height, 3600.0) + assert_eq(facts.scroll_width, 800.0) + assert_eq(facts.scroll_height, 3600.0) + assert_true(facts.content_width >= 0.0) + }) +} + +///| +test "diff geometry excludes owned zones but retains foreign zone geometry" { + let (foreign, excluded_height) = code_editor_foreign_zone_geometry( + [ + ("owned-a", 2, 19.0), + ("foreign-a", 2, 7.0), + ("owned-b", 5, 11.0), + ("foreign-b", 8, 13.0), + ("zero", 9, 0.0), + ("negative", 10, -5.0), + ], + ["owned-a", "owned-b"], + ) + assert_eq(excluded_height, 30.0) + assert_true( + foreign == + [ + { after_line_number: 2, height_in_px: 7.0 }, + { after_line_number: 8, height_in_px: 13.0 }, + ], + ) +} + +///| +test "managed zones replace one generation in exactly one transaction" { + let first = managed_view_zone_test_element() + let first_margin = managed_view_zone_test_element() + let skipped = managed_view_zone_test_element() + let second = managed_view_zone_test_element() + let mut transaction_count = 0 + let removed : Array[String] = [] + let added : Array[@editor_browser.ViewZone] = [] + let mut next_id = 0 + let ids = apply_managed_view_zones_transaction( + ["old-a", "old-b"], + [ + ManagedCodeEditorViewZoneSpec( + 2, + 19.0, + first, + margin_dom_node=first_margin, + min_width_in_px=512.0, + ordinal=7, + suppress_mouse_down=false, + ), + ManagedCodeEditorViewZoneSpec(3, 0.0, skipped), + ManagedCodeEditorViewZoneSpec(5, 7.0, second, show_in_hidden_areas=false), + ], + 42, + update => { + transaction_count += 1 + let accessor = @editor_browser.ViewZoneChangeAccessor::from_callbacks( + zone => { + added.push(zone) + next_id += 1 + "new-\{next_id}" + }, + id => removed.push(id), + _id => (), + ) + try! update(accessor) + }, + ) + assert_eq(transaction_count, 1) + assert_true(removed == ["old-a", "old-b"]) + assert_true(ids == ["new-1", "new-2"]) + assert_eq(added.length(), 2) + assert_eq(added[0].after_line_number, 2) + assert_true(added[0].height_in_px == Some(19.0)) + assert_true(added[0].show_in_hidden_areas == Some(true)) + assert_true(added[0].suppress_mouse_down == Some(false)) + assert_true(added[0].margin_dom_node is Some(_)) + assert_true(added[0].min_width_in_px == Some(512.0)) + assert_true(added[0].ordinal == Some(7)) + assert_eq(added[1].after_line_number, 5) + assert_true(added[1].height_in_px == Some(7.0)) + assert_true(added[1].show_in_hidden_areas == Some(false)) + assert_true(added[1].suppress_mouse_down == Some(true)) + assert_eq( + first.get_attribute("data-managed-view-zone-generation"), + Some("42"), + ) + assert_eq( + first_margin.get_attribute("data-managed-view-zone-generation"), + Some("42"), + ) + assert_true( + skipped.get_attribute("data-managed-view-zone-generation") is None, + ) + assert_eq( + second.get_attribute("data-managed-view-zone-generation"), + Some("42"), + ) +} + +///| +test "1000 managed zones teardown in one width-aware transaction" { + let previous_ids : Array[String] = [] + for index in 0..<1000 { + previous_ids.push("old-zone-\{index}") + } + let mut transaction_count = 0 + let mut layout_count = 0 + let mut remove_count = 0 + let next_ids = apply_managed_view_zones_transaction(previous_ids, [], 18, update => { + transaction_count += 1 + let accessor = @editor_browser.ViewZoneChangeAccessor::from_callbacks( + _zone => "unused", + _id => remove_count += 1, + _id => layout_count += 1, + ) + try! update(accessor) + }) + assert_true(next_ids.is_empty()) + assert_eq(transaction_count, 1) + assert_eq(layout_count, 1000) + assert_eq(remove_count, 1000) +} + +///| +test "managed zones no-op without an attached browser surface" { + with_test_viewer("alpha", viewer => { + let ids = viewer.apply_managed_view_zones( + ["old"], + [ManagedCodeEditorViewZoneSpec(1, 18.0, headless_test_element())], + 3, + ) + assert_eq(ids.length(), 0) + }) +} + +///| +test "stable viewport restore keeps a model-line anchor synchronously" { + with_test_viewer(long_document(100), viewer => { + viewer.test_set_viewport(width=800.0, height=180.0) + viewer.set_scroll_position(scroll_top=720.0, scroll_left=0.0) + let state = viewer.capture_stable_viewport_state().unwrap() + assert_eq(state.anchor_line_number(), 41) + assert_eq(state.anchor_viewport_offset(), 0.0) + assert_eq(state.scroll_left(), 0.0) + + viewer.test_set_layout_view_zones([(2, 30)]) + assert_eq(viewer.get_scroll_top(), 720.0) + assert_true(viewer.restore_stable_viewport_state(state)) + assert_eq(viewer.get_scroll_top(), 750.0) + }) +} + +///| +test "managed zone reveal uses the exact ordinal-aware zone top" { + with_test_viewer(long_document(100), viewer => { + viewer.test_set_viewport(width=800.0, height=180.0) + let ids : Array[String] = [] + viewer.test_view_model().view_layout.change_whitespace(accessor => { + ids.push(accessor.insert_whitespace(10, 1, 30.0, 0.0)) + ids.push(accessor.insert_whitespace(10, 2, 40.0, 0.0)) + }) + |> ignore + assert_eq(ids.length(), 2) + viewer.set_scroll_top(720.0) + assert_true(viewer.reveal_managed_view_zone_top(ids[0])) + assert_eq(viewer.get_scroll_top(), 180.0) + assert_true(viewer.reveal_managed_view_zone_top(ids[1])) + assert_eq(viewer.get_scroll_top(), 210.0) + assert_false(viewer.reveal_managed_view_zone_top("missing-zone")) + }) +} + +///| +test "stable viewport state is rejected after a model swap" { + let viewer = CodeEditorWidget::CodeEditorWidget() + defer viewer.dispose() + viewer.set_model(Some(test_model(long_document(50)))) + viewer.test_set_viewport(width=800.0, height=180.0) + viewer.set_scroll_top(180.0) + let state = viewer.capture_stable_viewport_state().unwrap() + viewer.set_model(Some(test_model(long_document(60)))) + assert_false(viewer.restore_stable_viewport_state(state)) + assert_eq(viewer.get_scroll_top(), 0.0) +} + +///| +test "diff geometry primitives return no state without a model" { + let viewer = CodeEditorWidget::CodeEditorWidget() + defer viewer.dispose() + assert_true(viewer.geometry_snapshot([]) is None) + assert_true(viewer.capture_stable_viewport_state() is None) +} diff --git a/editor/viewer/editor_configuration_state.mbt b/editor/internal/viewer/code_editor_widget/editor_configuration_state.mbt similarity index 97% rename from editor/viewer/editor_configuration_state.mbt rename to editor/internal/viewer/code_editor_widget/editor_configuration_state.mbt index 05d34d54f..331a41af7 100644 --- a/editor/viewer/editor_configuration_state.mbt +++ b/editor/internal/viewer/code_editor_widget/editor_configuration_state.mbt @@ -1,10 +1,10 @@ ///| -/// Viewer-lifetime configuration facts and the pure transitions that keep +/// CodeEditorWidget-lifetime configuration facts and the pure transitions that keep /// measured geometry, wrapping, and layout-event identity coherent. Browser -/// reads and View/ViewModel effects remain on `Viewer`; this owner never +/// reads and View/ViewModel effects remain on `CodeEditorWidget`; this owner never /// retains either domain. priv struct EditorConfigurationState { - mut options : ViewerOptions + mut options : CodeEditorOptions mut font_info : @config.FontInfo /// The measured container client width. Content width is derived from this /// source fact whenever options, font metrics, or the model digit band move @@ -23,7 +23,7 @@ priv struct EditorConfigurationState { /// readers. Model-derived counts and the measurement handshake stay on the /// mutable owner because they are transition state, not render inputs. priv struct EditorConfigurationSnapshot { - options : ViewerOptions + options : CodeEditorOptions font_info : @config.FontInfo width : Double content_width : Double @@ -56,7 +56,7 @@ priv struct EditorConfigurationEventSnapshot { ///| fn EditorConfigurationState::EditorConfigurationState( - options : ViewerOptions, + options : CodeEditorOptions, font_info : @config.FontInfo, initial_width? : Double = 0.0, initial_height? : Double = 0.0, @@ -109,8 +109,8 @@ fn EditorConfigurationState::event_snapshot( /// Replaces one complete options snapshot. `None` is the strict no-op path. fn EditorConfigurationState::replace_options( self : EditorConfigurationState, - options : ViewerOptions, -) -> ViewerOptions? { + options : CodeEditorOptions, +) -> CodeEditorOptions? { if self.options == options { None } else { @@ -219,7 +219,7 @@ fn EditorConfigurationState::set_line_counts( } ///| -/// Detach resets only the model-derived layout facts. Viewer-lifetime options, +/// Detach resets only the model-derived layout facts. CodeEditorWidget-lifetime options, /// font, measured geometry, wrapping, and the measure handshake survive. fn EditorConfigurationState::reset_model_line_counts( self : EditorConfigurationState, diff --git a/editor/viewer/editor_configuration_state_wbtest.mbt b/editor/internal/viewer/code_editor_widget/editor_configuration_state_wbtest.mbt similarity index 89% rename from editor/viewer/editor_configuration_state_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/editor_configuration_state_wbtest.mbt index 48e1e0591..7f82d7de2 100644 --- a/editor/viewer/editor_configuration_state_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/editor_configuration_state_wbtest.mbt @@ -1,6 +1,6 @@ ///| fn test_configuration_state( - options? : ViewerOptions = ViewerOptions::default(), + options? : CodeEditorOptions = CodeEditorOptions::default(), ) -> EditorConfigurationState { EditorConfigurationState( options, @@ -26,15 +26,15 @@ test "configuration owner initializes one coherent empty identity" { ///| test "configuration owner replaces only changed complete option snapshots" { let state = test_configuration_state() - assert_true(state.replace_options(ViewerOptions::default()) is None) - let changed = ViewerOptions(soft_wrap=true, tab_size=7) + assert_true(state.replace_options(CodeEditorOptions::default()) is None) + let changed = CodeEditorOptions(soft_wrap=true, tab_size=7) assert_true(state.replace_options(changed) is Some(_)) assert_true(state.snapshot().options == changed) } ///| test "measured size and font facts derive one wrap-column transition" { - let options = ViewerOptions(soft_wrap=true, font_size=12, line_height=18) + let options = CodeEditorOptions(soft_wrap=true, font_size=12, line_height=18) let state = test_configuration_state(options~) state.commit_measured_size(72.0, 180.0) assert_true(state.recompute_soft_wrap_column()) @@ -55,7 +55,7 @@ test "measured size and font facts derive one wrap-column transition" { ///| test "digit-band transition preserves outer width and reset stays model-scoped" { - let options = ViewerOptions(line_numbers_min_chars=1, folding=false) + let options = CodeEditorOptions(line_numbers_min_chars=1, folding=false) let state = test_configuration_state(options~) let initial_font = state.snapshot().font_info state.commit_font_info({ ..initial_font, max_digit_width: 10.0 }) |> ignore diff --git a/editor/viewer/editor_events.mbt b/editor/internal/viewer/code_editor_widget/editor_events.mbt similarity index 90% rename from editor/viewer/editor_events.mbt rename to editor/internal/viewer/code_editor_widget/editor_events.mbt index 2082cf269..e609dcff6 100644 --- a/editor/viewer/editor_events.mbt +++ b/editor/internal/viewer/code_editor_widget/editor_events.mbt @@ -1,5 +1,7 @@ ///| -fn Viewer::editor_context(self : Viewer) -> @hover_browser.EditorContext { +fn CodeEditorWidget::editor_context( + self : CodeEditorWidget, +) -> @hover_browser.EditorContext { let (mouse_posx, mouse_posy) = match self.content_hover_controller() { Some(hc) => (Some(hc.hover_mouse_posx), Some(hc.hover_mouse_posy)) None => (None, None) @@ -33,8 +35,8 @@ fn Viewer::editor_context(self : Viewer) -> @hover_browser.EditorContext { /// The live content-hover state for this editor. This is the one typed view /// over the central contribution wrapper; controller, widget/cache, and host /// scheduling callers all resolve through it. -fn Viewer::content_hover_contribution( - self : Viewer, +fn CodeEditorWidget::content_hover_contribution( + self : CodeEditorWidget, ) -> ContentHoverContributionState? { match self.contributions.lookup("editor.contrib.contentHover") { Some(entry) => @@ -50,8 +52,8 @@ fn Viewer::content_hover_contribution( /// The live hover controller instance for this editor — the central-registry /// equivalent of Monaco's `ContentHoverController.get(editor)` /// (`contentHoverController.ts`). `None` after the contribution disposed. -fn Viewer::content_hover_controller( - self : Viewer, +fn CodeEditorWidget::content_hover_controller( + self : CodeEditorWidget, ) -> @hover_browser.ContentHoverController? { match self.content_hover_contribution() { Some(state) => Some(state.controller) @@ -67,8 +69,8 @@ fn Viewer::content_hover_controller( /// before reacting; otherwise reacts immediately. The grace runner mirrors /// Monaco's "only schedule when not already scheduled". `posx` / `posy` are read /// from the last recorded pointer position (`record_hover_pointer`). -fn Viewer::on_editor_mouse_move( - self : Viewer, +fn CodeEditorWidget::on_editor_mouse_move( + self : CodeEditorWidget, target : @editor_browser.MouseTarget, ) -> Unit { // The public `onMouseMove` fired already (`emit_mouse_move`); this is the @@ -125,7 +127,9 @@ fn Viewer::on_editor_mouse_move( /// `enabled: 'on'`), drive the show-or-update path (Monaco's `showsOrWillShow`, /// here the `MouseMoved` → `start_delayed` arm, which clears when the pointer is /// off any token); otherwise, unless the dev sticky flag is set, hide. -fn Viewer::react_to_editor_mouse_move(self : Viewer) -> Unit { +fn CodeEditorWidget::react_to_editor_mouse_move( + self : CodeEditorWidget, +) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } if @hover.should_show_hover( hc.hover_settings.enabled, @@ -149,8 +153,8 @@ fn Viewer::react_to_editor_mouse_move(self : Viewer) -> Unit { /// controller handlers — which run from the pointer-handler seam that only /// carries a hit target — can evaluate `_isMouseOnContentHoverWidget` and /// `shouldShowHover`. Called by the input bridge before dispatching the move. -fn Viewer::record_hover_pointer( - self : Viewer, +fn CodeEditorWidget::record_hover_pointer( + self : CodeEditorWidget, posx : Double, posy : Double, modifiers : @hover.EventModifiers, @@ -166,7 +170,7 @@ fn Viewer::record_hover_pointer( /// The sticky mouse-getting-closer signal (Monaco `isHoverSticky && /// isMouseGettingCloser`) is measured here — the one point that reacts to a /// move — and threaded into the controller, which owns the keep/insist-update. -fn Viewer::run_hover_react(self : Viewer) -> Unit { +fn CodeEditorWidget::run_hover_react(self : CodeEditorWidget) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } hc.hover_react_pending = false match hc.hover_pending_target { @@ -185,7 +189,7 @@ fn Viewer::run_hover_react(self : Viewer) -> Unit { ///| /// Cancels any deferred mouse-move reaction (Monaco's /// `_reactToEditorMouseMoveRunner.cancel()`). -fn Viewer::cancel_hover_react(self : Viewer) -> Unit { +fn CodeEditorWidget::cancel_hover_react(self : CodeEditorWidget) -> Unit { match self.content_hover_controller() { Some(hc) => hc.cancel_react() None => () @@ -196,7 +200,7 @@ fn Viewer::cancel_hover_react(self : Viewer) -> Unit { /// The pointer is on the hover widget: keep the current hover and drop any /// pending hide. Monaco's `_shouldKeepCurrentHover` short-circuit when the mouse /// is over the content hover widget. -fn Viewer::keep_hover_open(self : Viewer) -> Unit { +fn CodeEditorWidget::keep_hover_open(self : CodeEditorWidget) -> Unit { self.cancel_hover_react() } @@ -205,7 +209,7 @@ fn Viewer::keep_hover_open(self : Viewer) -> Unit { /// pending target cannot re-open it after an imperative dismiss (mouse-down, /// fold toggle, model reset, Escape). Monaco's `hideContentHover()` pairs with /// `_cancelScheduler()`. Callers own their own re-render. -fn Viewer::dismiss_hover(self : Viewer) -> Unit { +fn CodeEditorWidget::dismiss_hover(self : CodeEditorWidget) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } hc.cancel_react() hc.cancel_operation_timers() @@ -222,7 +226,7 @@ fn Viewer::dismiss_hover(self : Viewer) -> Unit { /// (and the inline-completion dropdown guard, N-A here) suppress hiding; /// otherwise the widget hides. Wraps `dismiss_hover` with the repaint the /// imperative callers would otherwise own. -fn Viewer::hide_content_hover(self : Viewer) -> Unit { +fn CodeEditorWidget::hide_content_hover(self : CodeEditorWidget) -> Unit { if @hover.hover_sticky_debug { return } @@ -246,8 +250,8 @@ fn Viewer::hide_content_hover(self : Viewer) -> Unit { /// no widget exists or it is hidden). The widget is measured with /// `getBoundingClientRect`, so the coordinates are client-space (see the /// plan's Deviations on client- vs page). -fn Viewer::mouse_on_hover_widget( - self : Viewer, +fn CodeEditorWidget::mouse_on_hover_widget( + self : CodeEditorWidget, posx : Double, posy : Double, ) -> Bool { @@ -271,7 +275,9 @@ fn Viewer::mouse_on_hover_widget( /// rendered — the source's `!this._renderedHover` guard). Measures the widget /// with `getBoundingClientRect`, so the recorded client pointer and the /// rectangle are both client-space. -fn Viewer::hover_mouse_getting_closer(self : Viewer) -> Bool { +fn CodeEditorWidget::hover_mouse_getting_closer( + self : CodeEditorWidget, +) -> Bool { guard self.content_hover_contribution() is Some(state) else { return false } let hc = state.controller guard state.widget is Some(widget) else { return false } @@ -291,8 +297,8 @@ fn Viewer::hover_mouse_getting_closer(self : Viewer) -> Bool { /// Monaco `ContentHoverController._onEditorMouseDown`: a press that is not on the /// hover widget hides it; a press on it keeps it. Accepted presses set the /// controller's down-state before that decision, and mouse-up clears it. -fn Viewer::on_editor_mouse_down( - self : Viewer, +fn CodeEditorWidget::on_editor_mouse_down( + self : CodeEditorWidget, posx : Double, posy : Double, ) -> Unit { @@ -312,7 +318,7 @@ fn Viewer::on_editor_mouse_down( ///| /// Monaco `_onEditorMouseUp`: an accepted release retires the down-state used /// by hover-retention predicates; ignored mouse input leaves it unchanged. -fn Viewer::on_editor_mouse_up(self : Viewer) -> Unit { +fn CodeEditorWidget::on_editor_mouse_up(self : CodeEditorWidget) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } if hc.hover_ignore_mouse_events { return @@ -324,8 +330,8 @@ fn Viewer::on_editor_mouse_up(self : Viewer) -> Unit { /// Monaco `ContentHoverController._onEditorMouseLeave`: with mouse events not /// ignored and keep-open unset, cancel the scheduler and hide — unless the /// pointer is still on the widget (or the dev sticky flag is set). -fn Viewer::on_editor_mouse_leave( - self : Viewer, +fn CodeEditorWidget::on_editor_mouse_leave( + self : CodeEditorWidget, posx : Double, posy : Double, ) -> Unit { @@ -352,8 +358,8 @@ fn Viewer::on_editor_mouse_leave( ///| /// Monaco `ContentHoverController._onEditorScrollChanged`: a vertical or /// horizontal editor scroll hides the hover (unless mouse events are ignored). -fn Viewer::on_editor_scroll_changed( - self : Viewer, +fn CodeEditorWidget::on_editor_scroll_changed( + self : CodeEditorWidget, change : @view_layout.ScrollChange, ) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } @@ -369,8 +375,8 @@ fn Viewer::on_editor_scroll_changed( /// Fans one editor event to every controller in a fixed order and applies /// their effects; the contribution model with imperative sub-updates. /// Hover is the only controller today. -fn Viewer::dispatch_editor_event( - self : Viewer, +fn CodeEditorWidget::dispatch_editor_event( + self : CodeEditorWidget, event : @hover_browser.EditorEvent, sticky_mouse_getting_closer? : Bool = false, ) -> Unit { @@ -402,8 +408,8 @@ fn Viewer::dispatch_editor_event( /// widget (Monaco `_setCurrentResult` → `_showHover`/`_hideHover`). The hover /// highlight is baked into the line HTML as a span decoration, so a changed /// hover view also invalidates the rendered lines. -fn Viewer::after_hover_transition( - self : Viewer, +fn CodeEditorWidget::after_hover_transition( + self : CodeEditorWidget, before : @hover.HoverController, ) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } @@ -430,8 +436,8 @@ fn new_hover_cancellation_source() -> @language.CancellationTokenSource { /// Interprets a hover transition. Monaco's `@hover.HoverOperation.start(Delayed)` /// schedules only the async trigger and the loading message up front; the sync /// trigger is scheduled by the async trigger (see `hover_trigger_async`). -fn Viewer::apply_hover_effect( - self : Viewer, +fn CodeEditorWidget::apply_hover_effect( + self : CodeEditorWidget, effect : @hover.HoverEffect, ) -> Unit { match effect { @@ -498,8 +504,8 @@ fn Viewer::apply_hover_effect( ///| /// Programmatic wrapper entry matching `startShowingAtRange`: constructs the /// priority-zero range anchor and forwards mode, source, and focus unchanged. -fn Viewer::start_showing_hover_at_range( - self : Viewer, +fn CodeEditorWidget::start_showing_hover_at_range( + self : CodeEditorWidget, range : @base_common.Range, mode : @hover.HoverStartMode, source : @hover.HoverStartSource, @@ -534,8 +540,8 @@ fn Viewer::start_showing_hover_at_range( /// the operation to `SecondWait`, schedule the sync compute at /// `hover_second_wait_ms`, and kick the streamed async compute. No view changes /// yet (emission is still suppressed), so no re-render is needed here. -fn Viewer::hover_trigger_async( - self : Viewer, +fn CodeEditorWidget::hover_trigger_async( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Unit { guard self.content_hover_contribution() is Some(state) else { return } @@ -574,8 +580,8 @@ fn Viewer::hover_trigger_async( ///| /// Monaco `_triggerSyncComputation` fired (at the full delay): run the sync /// participants and feed their parts into the operation. -fn Viewer::hover_trigger_sync( - self : Viewer, +fn CodeEditorWidget::hover_trigger_sync( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } @@ -593,8 +599,8 @@ fn Viewer::hover_trigger_sync( ///| /// Monaco `_triggerLoadingMessage` fired (at three times the delay): show the /// loading message if the async compute is still pending. -fn Viewer::hover_trigger_loading( - self : Viewer, +fn CodeEditorWidget::hover_trigger_loading( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } @@ -610,7 +616,9 @@ fn Viewer::hover_trigger_loading( ///| /// Builds a content-hover computer over the current viewer services (Monaco /// `ContentHoverWidgetWrapper._initializeHoverParticipants`). -fn Viewer::make_hover_computer(self : Viewer) -> @hover.ContentHoverComputer { +fn CodeEditorWidget::make_hover_computer( + self : CodeEditorWidget, +) -> @hover.ContentHoverComputer { let services : @hover.HoverParticipantServices = { languages: self.services.languages, marker_decorations: self.services.marker_decorations, @@ -620,8 +628,8 @@ fn Viewer::make_hover_computer(self : Viewer) -> @hover.ContentHoverComputer { ///| /// Runs the synchronous hover participants for the current anchor. -fn Viewer::compute_hover_sync( - self : Viewer, +fn CodeEditorWidget::compute_hover_sync( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Array[@hover.HoverPart] { guard self.content_hover_controller() is Some(hc) else { return [] } @@ -644,8 +652,8 @@ fn Viewer::compute_hover_sync( /// participant's parts are fed to the operation as they resolve, then the /// operation is marked done. Results carry the requesting model and anchor token /// so stale responses are dropped at their guards. -fn Viewer::kick_hover_async( - self : Viewer, +fn CodeEditorWidget::kick_hover_async( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Unit { guard self.content_hover_contribution() is Some(state) else { return } @@ -670,8 +678,8 @@ fn Viewer::kick_hover_async( } ///| -fn Viewer::hover_async_item( - self : Viewer, +fn CodeEditorWidget::hover_async_item( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, parts : Array[@hover.HoverPart], ) -> Unit { @@ -687,8 +695,8 @@ fn Viewer::hover_async_item( } ///| -fn Viewer::hover_async_done( - self : Viewer, +fn CodeEditorWidget::hover_async_done( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } @@ -703,8 +711,8 @@ fn Viewer::hover_async_done( } ///| -fn Viewer::is_current_hover_request( - self : Viewer, +fn CodeEditorWidget::is_current_hover_request( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Bool { if self.disposed { @@ -720,8 +728,8 @@ fn Viewer::is_current_hover_request( /// Fires the public hover-resolution status exactly once per anchor, when the /// operation completes (reaches `Idle`): `ok` reports whether any hover content /// was produced. Replaces the old per-result notification. -fn Viewer::maybe_notify_hover_resolved( - self : Viewer, +fn CodeEditorWidget::maybe_notify_hover_resolved( + self : CodeEditorWidget, request : @hover.HoverRequestStamp, ) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } @@ -747,7 +755,7 @@ fn Viewer::maybe_notify_hover_resolved( /// Syncs the hover highlight into the model's decoration storage (Monaco's /// hover-owned decorations collection: `delta_decorations` on show/hide). /// Runs at flush time — every hover state change schedules a flush. -fn Viewer::sync_hover_decorations(self : Viewer) -> Unit { +fn CodeEditorWidget::sync_hover_decorations(self : CodeEditorWidget) -> Unit { guard self.content_hover_controller() is Some(hc) else { return } guard self.get_model() is Some(model) else { return } let decorations = hc.hover.highlight_decorations() @@ -767,7 +775,10 @@ fn Viewer::sync_hover_decorations(self : Viewer) -> Unit { } ///| -fn Viewer::is_current_model(self : Viewer, model : @model.TextModel) -> Bool { +fn CodeEditorWidget::is_current_model( + self : CodeEditorWidget, + model : @model.TextModel, +) -> Bool { match self.get_model() { Some(current) => current.same(model) None => false diff --git a/editor/viewer/editor_extensions.mbt b/editor/internal/viewer/code_editor_widget/editor_extensions.mbt similarity index 91% rename from editor/viewer/editor_extensions.mbt rename to editor/internal/viewer/code_editor_widget/editor_extensions.mbt index 4af809db0..e0806675b 100644 --- a/editor/viewer/editor_extensions.mbt +++ b/editor/internal/viewer/code_editor_widget/editor_extensions.mbt @@ -7,19 +7,19 @@ // `KeybindingsRegistry.registerKeybindingRule` + `CommandsRegistry`, // `EditorCommand.runEditorCommand`). // -// Structural deviation: contribution glue stays with the root-owned `Viewer` +// Structural deviation: contribution glue stays with the root-owned `CodeEditorWidget` // state, and there is no DI/services collection to port, so the registry // inverts *dispatch and lifecycle* (who instantiates contributions, who routes // commands/keys) — not file placement. The `*_host.mbt` files stay here as the // contribution ctors this registry instantiates, preconditions are live -// predicates over the `Viewer` instead of `ContextKeyExpression`s, and +// predicates over the `CodeEditorWidget` instead of `ContextKeyExpression`s, and // registration happens in one initializer (`build_editor_registry`) because // MoonBit has no import-time side effects. ///| /// `EditorContributionInstantiation` (`editorExtensions.ts:34-65`). The /// readonly viewer has no idle scheduler, interaction tracker, or on-demand -/// `getContribution`, so every mode instantiates eagerly at `Viewer` +/// `getContribution`, so every mode instantiates eagerly at `CodeEditorWidget` /// construction; the deferred modes exist so a registration can carry its /// source's declared mode (content hover is `BeforeFirstInteraction`, /// comments `AfterFirstRender`). @@ -37,7 +37,7 @@ priv enum EditorContributionInstantiation { let _unused_contribution_modes : Array[EditorContributionInstantiation] = [Lazy] ///| -/// The concrete live contribution state stored in the per-Viewer registry. +/// The concrete live contribution state stored in the per-CodeEditorWidget registry. /// The product registry is closed and statically populated, so adding another /// contribution deliberately requires exhaustive construction, typed-access, /// and disposal updates here. @@ -58,7 +58,7 @@ priv enum EditorContributionInstance { } ///| -/// Viewer-lifetime content-hover state. The controller, lazily mounted widget, +/// CodeEditorWidget-lifetime content-hover state. The controller, lazily mounted widget, /// logical widget cache, and host scheduling policy form one contribution /// owner and survive model replacement together. priv struct ContentHoverContributionState { @@ -157,14 +157,14 @@ fn EditorContributionEntry::typed_lookup_available( } ///| -/// Direct typed lifecycle dispatch. The complete Viewer map remains installed +/// Direct typed lifecycle dispatch. The complete CodeEditorWidget map remains installed /// while every row tears down, so feature cleanup can still use typed central /// accessors. The explicit lifecycle state rejects recursive/repeated teardown /// and makes later typed lookup return `None` without removing the row before /// the full ordered disposal pass finishes. fn EditorContributionEntry::dispose( self : EditorContributionEntry, - viewer : Viewer, + viewer : CodeEditorWidget, ) -> Unit { guard self.lifecycle == ContributionLive else { return } self.lifecycle = ContributionDisposing @@ -234,7 +234,7 @@ priv enum EditorContributionsLifecycle { } derive(Eq) ///| -/// Monaco `CodeEditorContributions._instances`: the sole per-Viewer concrete +/// Monaco `CodeEditorContributions._instances`: the sole per-CodeEditorWidget concrete /// contribution store and its two-phase disposal state. priv struct EditorContributions { instances : Map[String, EditorContributionEntry] @@ -268,7 +268,7 @@ fn EditorContributions::lookup( fn EditorContributions::instantiate( self : EditorContributions, description : EditorContributionDescription, - viewer : Viewer, + viewer : CodeEditorWidget, ) -> Unit { guard self.lifecycle == ContributionsLive else { return } if self.instances.contains(description.id) { @@ -292,7 +292,7 @@ fn EditorContributions::instantiate( /// its own synchronous cleanup. Only after the full pass does lookup retire. fn EditorContributions::dispose_behavior( self : EditorContributions, - viewer : Viewer, + viewer : CodeEditorWidget, ) -> Unit { guard self.lifecycle == ContributionsLive else { return } for _, contribution in self.instances { @@ -322,24 +322,26 @@ fn EditorContributions::dispose_retained_browser_resources( ///| /// `IEditorContributionDescription` (`editorExtensions.ts:68-72`): id + ctor -/// closure, instantiated once per `Viewer`. Construction and initialization +/// closure, instantiated once per `CodeEditorWidget`. Construction and initialization /// are split so the concrete entry can be installed before any synchronous /// typed lookup made by the initializer. priv struct EditorContributionDescription { id : String + kind : CodeEditorContributionKind instantiation : EditorContributionInstantiation - ctor : (Viewer) -> EditorContributionInstance - initialize : (Viewer) -> Array[@base_common.Disposable] + ctor : (CodeEditorWidget) -> EditorContributionInstance + initialize : (CodeEditorWidget) -> Array[@base_common.Disposable] } ///| fn EditorContributionDescription::EditorContributionDescription( id : String, + kind : CodeEditorContributionKind, instantiation : EditorContributionInstantiation, - ctor : (Viewer) -> EditorContributionInstance, - initialize : (Viewer) -> Array[@base_common.Disposable], + ctor : (CodeEditorWidget) -> EditorContributionInstance, + initialize : (CodeEditorWidget) -> Array[@base_common.Disposable], ) -> EditorContributionDescription { - { id, instantiation, ctor, initialize } + { id, kind, instantiation, ctor, initialize } } ///| @@ -377,8 +379,8 @@ priv struct EditorCommand { menu_keybinding_label : String? menu_placements : Array[EditorMenuPlacement] registration_order : Int - precondition : (Viewer) -> Bool - run : (Viewer) -> Unit + precondition : (CodeEditorWidget) -> Bool + run : (CodeEditorWidget) -> Unit } ///| @@ -387,10 +389,10 @@ priv struct EditorCommand { /// `undefined`; keybinding `kbExpr` remains a separate rule predicate. priv struct CoreEditorCommand { id : String - precondition : ((Viewer) -> Bool)? + precondition : ((CodeEditorWidget) -> Bool)? default_args : CursorCoreCommandArgs run_core_editor_command : ( - Viewer, + CodeEditorWidget, @view_model.ViewModel, CursorCoreCommandArgs, ) -> Bool @@ -421,7 +423,7 @@ priv struct KeybindingRule { ctrl_cmd : Bool shift : Bool command_id : String - when : (Viewer) -> Bool + when : (CodeEditorWidget) -> Bool } ///| @@ -438,9 +440,10 @@ priv struct EditorContributionRegistry { fn EditorContributionRegistry::register_editor_contribution( self : EditorContributionRegistry, id : String, + kind : CodeEditorContributionKind, instantiation : EditorContributionInstantiation, - ctor : (Viewer) -> EditorContributionInstance, - initialize : (Viewer) -> Array[@base_common.Disposable], + ctor : (CodeEditorWidget) -> EditorContributionInstance, + initialize : (CodeEditorWidget) -> Array[@base_common.Disposable], ) -> Unit { // Monaco's description registry is first-wins. Reject the later row before // its constructor, initializer, listener, or dispose path can exist. @@ -450,7 +453,7 @@ fn EditorContributionRegistry::register_editor_contribution( } } self.contributions.push( - EditorContributionDescription(id, instantiation, ctor, initialize), + EditorContributionDescription(id, kind, instantiation, ctor, initialize), ) } @@ -460,8 +463,8 @@ fn EditorContributionRegistry::register_editor_contribution( fn EditorContributionRegistry::register_editor_command( self : EditorContributionRegistry, id : String, - precondition : (Viewer) -> Bool, - run : (Viewer) -> Unit, + precondition : (CodeEditorWidget) -> Bool, + run : (CodeEditorWidget) -> Unit, keybindings : Array[(String, Bool, Bool)], shift_keybindings? : Array[(String, Bool, Bool)] = [], title? : String, @@ -507,7 +510,7 @@ fn EditorContributionRegistry::register_editor_command( /// nonempty groups. fn EditorContributionRegistry::resolve_editor_menu( self : EditorContributionRegistry, - viewer : Viewer, + viewer : CodeEditorWidget, menu_id : EditorMenuId, ) -> Array[@contextmenu_browser.ContextMenuEntry] { let candidates : Array[ResolvedEditorMenuCandidate] = [] @@ -571,7 +574,7 @@ fn compare_resolved_editor_menu_candidate( fn EditorContributionRegistry::register_core_editor_command( self : EditorContributionRegistry, command : CoreEditorCommand, - keybinding_when : (Viewer) -> Bool, + keybinding_when : (CodeEditorWidget) -> Bool, keybindings : Array[(String, Bool, Bool)], shift_keybindings? : Array[(String, Bool, Bool)] = [], ) -> Unit { @@ -606,7 +609,7 @@ fn EditorContributionRegistry::register_core_editor_command( /// core member. fn CoreEditorCommand::run_editor_command( self : CoreEditorCommand, - viewer : Viewer, + viewer : CodeEditorWidget, args? : CursorCoreCommandArgs, ) -> Bool { guard viewer.is_code_presentation() else { return false } @@ -628,7 +631,7 @@ fn CoreEditorCommand::run_editor_command( /// process-wide registration table by its source ID. fn EditorContributionRegistry::run_core_editor_command( self : EditorContributionRegistry, - viewer : Viewer, + viewer : CodeEditorWidget, view_model : @view_model.ViewModel, id : String, args? : CursorCoreCommandArgs, @@ -655,7 +658,7 @@ fn EditorContributionRegistry::run_core_editor_command( /// model (for example the registered keyboard resolver). fn EditorContributionRegistry::run_editor_command( self : EditorContributionRegistry, - viewer : Viewer, + viewer : CodeEditorWidget, id : String, args? : CursorCoreCommandArgs, ) -> Bool { @@ -677,14 +680,17 @@ fn EditorContributionRegistry::run_editor_command( /// `codeEditorWidget.ts:344`): every registered contribution is constructed /// for this viewer, in registration order (= the hand-wiring construction /// order the registry replaced), keyed by id like the source's `_instances` -/// map. Instances live until `Viewer::dispose`; `set_model` does not +/// map. Instances live until `CodeEditorWidget::dispose`; `set_model` does not /// re-create them, matching the source. -fn EditorContributionRegistry::instantiate_all( +fn EditorContributionRegistry::instantiate_enabled( self : EditorContributionRegistry, - viewer : Viewer, + viewer : CodeEditorWidget, + descriptors : CodeEditorContributionDescriptors, ) -> Unit { for description in self.contributions { - viewer.contributions.instantiate(description, viewer) + if descriptors.allows(description.kind) { + viewer.contributions.instantiate(description, viewer) + } } } @@ -694,7 +700,7 @@ fn EditorContributionRegistry::instantiate_all( /// the key (callers `preventDefault` on `true`). fn EditorContributionRegistry::dispatch_keybinding( self : EditorContributionRegistry, - viewer : Viewer, + viewer : CodeEditorWidget, key : String, alt~ : Bool, ctrl_cmd~ : Bool, @@ -724,7 +730,7 @@ priv struct EditorRegistryHolder { ///| /// Backing cell for the singleton: built on first use rather than in a -/// global initializer — the registered ctor/run closures reach `Viewer` +/// global initializer — the registered ctor/run closures reach `CodeEditorWidget` /// methods whose dispatch consults the registry back, which MoonBit's /// toplevel-definition cycle check would reject as an initializer cycle. let editor_registry_holder : EditorRegistryHolder = { registry: None } diff --git a/editor/viewer/editor_extensions_wbtest.mbt b/editor/internal/viewer/code_editor_widget/editor_extensions_wbtest.mbt similarity index 87% rename from editor/viewer/editor_extensions_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/editor_extensions_wbtest.mbt index 9c2c2a748..c96d6b5d3 100644 --- a/editor/viewer/editor_extensions_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/editor_extensions_wbtest.mbt @@ -1,10 +1,12 @@ // Contribution/command registry checks (deviations-closeout Track D): the -// registered features instantiate per `Viewer` through the registry and +// registered features instantiate per `CodeEditorWidget` through the registry and // dispose with it; the keybinding table gates on live preconditions. ///| test "contributions instantiate centrally in the reviewed order and expose every typed variant" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget( + contributions=CodeEditorContributionDescriptors::standalone(), + ) let expected = [ "agentFeedback.editorInputContribution", "agentFeedback.editorWidgetContribution", @@ -65,6 +67,69 @@ test "contributions instantiate centrally in the reviewed order and expose every } } +///| +test "diff pane descriptors keep code navigation but exclude nested rich and diff contributions" { + let viewer = CodeEditorWidget::CodeEditorWidget( + contributions=CodeEditorContributionDescriptors::diff_pane(), + ) + let expected = [ + "agentFeedback.editorInputContribution", + "agentFeedback.editorWidgetContribution", + @folding_browser.FOLDING_CONTROLLER_ID, + "editor.contrib.contentHover", + context_menu_contribution_id, + definition_contribution_id, + references_contribution_id, + ] + let actual : Array[String] = [] + for id, _ in viewer.contributions.instances { + actual.push(id) + } + assert_eq(actual, expected) + assert_true(viewer.agent_feedback_input_contribution() is Some(_)) + assert_true(viewer.agent_feedback_widget_contribution() is Some(_)) + assert_true(viewer.folding_controller() is Some(_)) + assert_true(viewer.content_hover_controller() is Some(_)) + assert_true(viewer.context_menu_contribution() is Some(_)) + assert_true(viewer.definition_contribution() is Some(_)) + assert_true(viewer.references_contribution() is Some(_)) + assert_true(viewer.markdown_comment_contribution() is None) + assert_true(viewer.quick_diff_contribution() is None) + + viewer.dispose() + viewer.dispose() + assert_eq(viewer.contributions.instances.length(), 0) +} + +///| +test "peek preview descriptors cannot recursively create references markdown or quick diff" { + let viewer = CodeEditorWidget::CodeEditorWidget( + contributions=CodeEditorContributionDescriptors::peek_preview(), + ) + let expected = [ + "editor.contrib.contentHover", context_menu_contribution_id, definition_contribution_id, + ] + let actual : Array[String] = [] + for id, _ in viewer.contributions.instances { + actual.push(id) + } + assert_eq(actual, expected) + assert_true(viewer.content_hover_controller() is Some(_)) + assert_true(viewer.context_menu_contribution() is Some(_)) + assert_true(viewer.definition_contribution() is Some(_)) + assert_true(viewer.references_contribution() is None) + assert_true(viewer.markdown_comment_contribution() is None) + assert_true(viewer.quick_diff_contribution() is None) + assert_true(viewer.folding_controller() is None) + assert_true(viewer.agent_feedback_input_contribution() is None) + assert_true(viewer.agent_feedback_widget_contribution() is None) + assert_false(viewer.references_peek_available()) + + viewer.dispose() + viewer.dispose() + assert_eq(viewer.contributions.instances.length(), 0) +} + ///| test "duplicate contribution ids are first-wins before later side effects" { let first_constructed = Ref(0) @@ -80,6 +145,7 @@ test "duplicate contribution ids are first-wins before later side effects" { } registry.register_editor_contribution( "test.duplicate", + QuickDiff, Eager, viewer => { first_constructed.val += 1 @@ -98,6 +164,7 @@ test "duplicate contribution ids are first-wins before later side effects" { ) registry.register_editor_contribution( "test.duplicate", + QuickDiff, Eager, viewer => { second_constructed.val += 1 @@ -115,11 +182,17 @@ test "duplicate contribution ids are first-wins before later side effects" { }, ) assert_eq(registry.contributions.length(), 1) - let viewer = Viewer::Viewer() - registry.instantiate_all(viewer) + let viewer = CodeEditorWidget::CodeEditorWidget() + registry.instantiate_enabled( + viewer, + CodeEditorContributionDescriptors::standalone(), + ) // Re-initializing through another registry pass is also first-wins against - // the already-installed Viewer row. - registry.instantiate_all(viewer) + // the already-installed CodeEditorWidget row. + registry.instantiate_enabled( + viewer, + CodeEditorContributionDescriptors::standalone(), + ) assert_eq(first_constructed.val, 1) assert_eq(first_initialized.val, 1) assert_eq(second_constructed.val, 0) @@ -140,7 +213,7 @@ test "duplicate contribution ids are first-wins before later side effects" { ///| test "direct entry disposal retires typed lookup without removing the row early" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() guard viewer.contributions.instances.get("editor.contrib.contentHover") is Some(hover) else { fail("missing hover contribution entry") @@ -160,7 +233,7 @@ test "direct entry disposal retires typed lookup without removing the row early" ///| test "viewer disposes all nine real rows once in order while the map stays installed" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let expected_ids = [ "agentFeedback.editorInputContribution", "agentFeedback.editorWidgetContribution", @@ -245,7 +318,7 @@ test "viewer disposes all nine real rows once in order while the map stays insta ///| test "contribution wrapper retires lookup between behavior and retained-resource phases" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let hover_state = viewer.content_hover_contribution().unwrap() viewer.contributions.dispose_behavior(viewer) assert_true(viewer.contributions.lifecycle == ContributionsDisposing) @@ -272,8 +345,8 @@ test "contribution wrapper retires lookup between behavior and retained-resource ///| test "two viewers isolate mutable contribution state and disposal owners" { - let first = Viewer::Viewer() - let second = Viewer::Viewer() + let first = CodeEditorWidget::CodeEditorWidget() + let second = CodeEditorWidget::CodeEditorWidget() let first_input = first.agent_feedback_input_contribution().unwrap() let second_input = second.agent_feedback_input_contribution().unwrap() let first_widgets = first.agent_feedback_widget_contribution().unwrap() @@ -302,9 +375,9 @@ test "two viewers isolate mutable contribution state and disposal owners" { assert_false(physical_equal(first_definition, second_definition)) assert_false(physical_equal(first_references, second_references)) - // Give every concrete controller/state a Viewer-specific mutable witness. + // Give every concrete controller/state a CodeEditorWidget-specific mutable witness. // These values deliberately differ so an accidental shared carrier or - // cross-Viewer lookup is observable before either Viewer is disposed. + // cross-CodeEditorWidget lookup is observable before either CodeEditorWidget is disposed. first_input.visible = true first_input.hover_line_number = 11 second_input.visible = false @@ -319,8 +392,6 @@ test "two viewers isolate mutable contribution state and disposal owners" { first_hover.hover_notified_token = 11 second_hover.hover_ignore_mouse_events = true second_hover.hover_notified_token = 22 - first_references.generation = 303 - second_references.generation = 404 let first_disposals : Array[String] = [] let second_disposals : Array[String] = [] @@ -394,8 +465,6 @@ test "two viewers isolate mutable contribution state and disposal owners" { assert_false(second_hover.hover_keep_open) assert_false(first_hover.hover_ignore_mouse_events) assert_true(second_hover.hover_ignore_mouse_events) - assert_eq(first_references.generation, 303) - assert_eq(second_references.generation, 404) first.dispose() @@ -498,7 +567,7 @@ test "two viewers isolate mutable contribution state and disposal owners" { ///| test "model removal and reattach preserve central contribution identities" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let input = viewer.agent_feedback_input_contribution().unwrap() let widgets = viewer.agent_feedback_widget_contribution().unwrap() let folding = viewer.folding_controller().unwrap() @@ -553,7 +622,7 @@ test "model removal and reattach preserve central contribution identities" { ///| test "keybinding dispatch honors preconditions" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() // `editor.action.hideHover` is bound to Escape but gated on // `hoverVisible`; with no hover the key falls through unhandled. assert_false( @@ -654,9 +723,9 @@ test "cursor navigation registers exactly 16 primary rules after contributions" } ///| -test "Peek References registers the exact Shift F12 chord" { +test "Go to References registers the exact Shift F12 chord" { let rules = editor_registry().keybindings.filter(rule => { - rule.command_id == "editor.action.referenceSearch.trigger" + rule.command_id == "editor.action.goToReferences" }) assert_eq(rules.length(), 1) assert_eq(rules[0].key, "F12") @@ -669,7 +738,7 @@ test "Peek References registers the exact Shift F12 chord" { test "definition context menu suppresses unavailable actions" { let services = definition_navigation_services([]) defer services.dispose() - let viewer = Viewer::Viewer(services~) + let viewer = CodeEditorWidget::CodeEditorWidget(services~) defer viewer.dispose() let model = definition_navigation_test_model( "file:///headless-context-menu.mbt", @@ -677,7 +746,7 @@ test "definition context menu suppresses unavailable actions" { defer model.dispose() viewer.set_model(Some(model)) - // A provider makes Go to Definition available, but a headless Viewer cannot + // A provider makes Go to Definition available, but a headless CodeEditorWidget cannot // own Peek UI, so only the available top-level action remains. let headless_entries = editor_registry().resolve_editor_menu( viewer, @@ -689,7 +758,7 @@ test "definition context menu suppresses unavailable actions" { fail("expected only the available Go to Definition action") } - let unavailable = Viewer::Viewer() + let unavailable = CodeEditorWidget::CodeEditorWidget() defer unavailable.dispose() let unavailable_model = definition_navigation_test_model( "file:///no-provider-context-menu.mbt", @@ -737,7 +806,7 @@ test "menu resolution orders groups and inserts separators only between visible title="Hidden", menu_placements=[{ menu_id: EditorContext, group: "00_hidden", order: 1.0 }], ) - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() defer viewer.dispose() let entries = registry.resolve_editor_menu(viewer, EditorContext) assert_eq(entries.length(), 3) diff --git a/editor/viewer/exports.mbt b/editor/internal/viewer/code_editor_widget/exports.mbt similarity index 81% rename from editor/viewer/exports.mbt rename to editor/internal/viewer/code_editor_widget/exports.mbt index c1d6fe207..e1787ad53 100644 --- a/editor/viewer/exports.mbt +++ b/editor/internal/viewer/code_editor_widget/exports.mbt @@ -2,8 +2,8 @@ /// `getLineDecorations` (`codeEditorWidget.ts:1342-1348`): the model's line /// query filtered by this viewer's editor id and its /// `renderValidationDecorations` option. -pub fn Viewer::get_line_decorations( - self : Viewer, +pub fn CodeEditorWidget::get_line_decorations( + self : CodeEditorWidget, line_number : Int, ) -> Array[@model.ModelDecoration] { guard self.get_model() is Some(model) else { return [] } @@ -18,8 +18,8 @@ pub fn Viewer::get_line_decorations( ///| /// `getDecorationsInRange` (`codeEditorWidget.ts:1350-1356`). -pub fn Viewer::get_decorations_in_range( - self : Viewer, +pub fn CodeEditorWidget::get_decorations_in_range( + self : CodeEditorWidget, range : @base_common.Range, ) -> Array[@model.ModelDecoration] { guard self.get_model() is Some(model) else { return [] } @@ -96,8 +96,8 @@ pub fn EditorDecorationsCollection::get_ranges( ///| /// Fires on editor mouseup with the hit-tested target — Monaco's /// `onMouseUp`. -pub fn Viewer::on_mouse_up( - self : Viewer, +pub fn CodeEditorWidget::on_mouse_up( + self : CodeEditorWidget, listener : (@editor_browser.EditorMouseEvent) -> Unit, ) -> @base_common.Disposable { self.did_mouse_up.event(listener) @@ -106,8 +106,8 @@ pub fn Viewer::on_mouse_up( ///| /// Fires on editor mousemove with the hit-tested target — Monaco's /// `onMouseMove`. -pub fn Viewer::on_mouse_move( - self : Viewer, +pub fn CodeEditorWidget::on_mouse_move( + self : CodeEditorWidget, listener : (@editor_browser.EditorMouseEvent) -> Unit, ) -> @base_common.Disposable { self.did_mouse_move.event(listener) @@ -116,8 +116,8 @@ pub fn Viewer::on_mouse_move( ///| /// Fires on an editor context-menu gesture with the hit-tested target — /// Monaco's `onContextMenu`. -pub fn Viewer::on_context_menu( - self : Viewer, +pub fn CodeEditorWidget::on_context_menu( + self : CodeEditorWidget, listener : (@editor_browser.EditorMouseEvent) -> Unit, ) -> @base_common.Disposable { self.did_context_menu.event(listener) @@ -126,8 +126,8 @@ pub fn Viewer::on_context_menu( ///| /// Fires when the pointer leaves the editor — Monaco's `onMouseLeave` /// (`IPartialEditorMouseEvent`; the target is always `None` for leaves). -pub fn Viewer::on_mouse_leave( - self : Viewer, +pub fn CodeEditorWidget::on_mouse_leave( + self : CodeEditorWidget, listener : (@editor_browser.PartialEditorMouseEvent) -> Unit, ) -> @base_common.Disposable { self.did_mouse_leave.event(listener) @@ -136,8 +136,8 @@ pub fn Viewer::on_mouse_leave( ///| /// Fires after a changed hidden-area mapping has completed its internal view /// events, cursor/decorations reprojection, layout, and viewport recovery. -pub fn Viewer::on_did_change_hidden_areas( - self : Viewer, +pub fn CodeEditorWidget::on_did_change_hidden_areas( + self : CodeEditorWidget, listener : () -> Unit, ) -> @base_common.Disposable { self.did_change_hidden_areas.event(_ => listener()) @@ -146,7 +146,7 @@ pub fn Viewer::on_did_change_hidden_areas( ///| /// The editor type marker, Monaco's `getEditorType()` → /// `EditorType.ICodeEditor`. -pub fn Viewer::get_editor_type(self : Viewer) -> String { +pub fn CodeEditorWidget::get_editor_type(self : CodeEditorWidget) -> String { ignore(self) "vs.editor.ICodeEditor" } @@ -154,8 +154,8 @@ pub fn Viewer::get_editor_type(self : Viewer) -> String { ///| /// Whether focus is anywhere inside the editor widget, including hover /// widgets — `hasWidgetFocus()`. -pub fn Viewer::has_widget_focus(self : Viewer) -> Bool { - match self.current_browser_presentation() { +pub fn CodeEditorWidget::has_widget_focus(self : CodeEditorWidget) -> Bool { + match self.current_code_browser_data() { Some(presentation) => self.has_text_focus() || presentation.contains_active_element() None => false @@ -169,8 +169,8 @@ pub fn Viewer::has_widget_focus(self : Viewer) -> Bool { /// applied and any secondaries are dropped. Like Monaco, an empty array is /// rejected as a no-op (Monaco throws an "Invalid arguments" error there; the /// readonly viewer's mutators never throw). -pub fn Viewer::set_selections( - self : Viewer, +pub fn CodeEditorWidget::set_selections( + self : CodeEditorWidget, selections : Array[@core.Selection], source? : String = "api", reason? : @editor_api.CursorChangeReason = NotSet, @@ -189,8 +189,8 @@ pub fn Viewer::set_selections( /// Content-space top of a 1-based model position, Monaco's `getTopForPosition` /// (`cew:600`) — always excludes the view zone directly above the line. No model /// → `-1`. -pub fn Viewer::get_top_for_position( - self : Viewer, +pub fn CodeEditorWidget::get_top_for_position( + self : CodeEditorWidget, line_number : Int, column : Int, ) -> Double { @@ -206,8 +206,8 @@ pub fn Viewer::get_top_for_position( /// `getBottomForLineNumber` (`cew:616`): the offset after the line, computed at /// the line's max column (`Number.MAX_SAFE_INTEGER`, validated down). No model → /// `-1`. -pub fn Viewer::get_bottom_for_line_number( - self : Viewer, +pub fn CodeEditorWidget::get_bottom_for_line_number( + self : CodeEditorWidget, line_number : Int, ) -> Double { self.get_vertical_offset_after_position( @@ -222,8 +222,8 @@ pub fn Viewer::get_bottom_for_line_number( /// `getLineHeightForPosition` (`cew:623`). No model → `-1`; a position inside a /// hidden region → `0`; otherwise the configured uniform line height. /// Like Monaco the raw position is not validated before the visibility test. -pub fn Viewer::get_line_height_for_position( - self : Viewer, +pub fn CodeEditorWidget::get_line_height_for_position( + self : CodeEditorWidget, position : @base_common.Position, ) -> Double { guard (self.get_model(), self.get_view_model()) is (Some(_), Some(view_model)) else { @@ -246,8 +246,8 @@ pub fn Viewer::get_line_height_for_position( /// full-width/emoji count as two), Monaco's `getVisibleColumnFromPosition` /// (`cew:641`): `visibleColumnFromColumn(...) + 1`. No model → the raw input /// column (the load-bearing no-model sentinel, not `-1`). -pub fn Viewer::get_visible_column_from_position( - self : Viewer, +pub fn CodeEditorWidget::get_visible_column_from_position( + self : CodeEditorWidget, position : @base_common.Position, ) -> Int { guard self.get_model() is Some(model) else { return position.column } @@ -265,7 +265,9 @@ pub fn Viewer::get_visible_column_from_position( /// All cursor model selections, Monaco's `getSelections` (`cew:790`). The viewer /// is single-selection, so this is a 1-element array (a documented prior scope /// cut). No model → `null`. -pub fn Viewer::get_selections(self : Viewer) -> Array[@core.Selection]? { +pub fn CodeEditorWidget::get_selections( + self : CodeEditorWidget, +) -> Array[@core.Selection]? { match self.get_selection() { Some(selection) => Some([selection]) None => None @@ -274,27 +276,36 @@ pub fn Viewer::get_selections(self : Viewer) -> Array[@core.Selection]? { ///| /// Reveals a 1-based model line, aligning its top into view (`Simple`). -pub fn Viewer::reveal_line(self : Viewer, line_number : Int) -> Unit { +pub fn CodeEditorWidget::reveal_line( + self : CodeEditorWidget, + line_number : Int, +) -> Unit { self.reveal_line_with(line_number, Simple) } ///| /// Reveals a 1-based model line, centering it (`Center`). -pub fn Viewer::reveal_line_in_center(self : Viewer, line_number : Int) -> Unit { +pub fn CodeEditorWidget::reveal_line_in_center( + self : CodeEditorWidget, + line_number : Int, +) -> Unit { self.reveal_line_with(line_number, Center) } ///| /// Reveals a 1-based model line near the top of the viewport (`NearTop`). -pub fn Viewer::reveal_line_near_top(self : Viewer, line_number : Int) -> Unit { +pub fn CodeEditorWidget::reveal_line_near_top( + self : CodeEditorWidget, + line_number : Int, +) -> Unit { self.reveal_line_with(line_number, NearTop) } ///| /// Reveals a 1-based model line centered only if it is currently outside the /// viewport (`CenterIfOutsideViewport`). -pub fn Viewer::reveal_line_in_center_if_outside_viewport( - self : Viewer, +pub fn CodeEditorWidget::reveal_line_in_center_if_outside_viewport( + self : CodeEditorWidget, line_number : Int, ) -> Unit { self.reveal_line_with(line_number, CenterIfOutsideViewport) @@ -304,8 +315,8 @@ pub fn Viewer::reveal_line_in_center_if_outside_viewport( /// Reveals a model line span `[start, end]` (1-based), aligning its top /// (`Simple`). Like Monaco's `revealLines`, this is a line-only reveal: /// horizontal scroll is not adjusted. -pub fn Viewer::reveal_lines( - self : Viewer, +pub fn CodeEditorWidget::reveal_lines( + self : CodeEditorWidget, start_line_number : Int, end_line_number : Int, ) -> Unit { @@ -314,8 +325,8 @@ pub fn Viewer::reveal_lines( ///| /// Reveals a model line span `[start, end]` (1-based), centering it (`Center`). -pub fn Viewer::reveal_lines_in_center( - self : Viewer, +pub fn CodeEditorWidget::reveal_lines_in_center( + self : CodeEditorWidget, start_line_number : Int, end_line_number : Int, ) -> Unit { @@ -324,8 +335,8 @@ pub fn Viewer::reveal_lines_in_center( ///| /// Reveals a model line span `[start, end]` (1-based) near the top (`NearTop`). -pub fn Viewer::reveal_lines_near_top( - self : Viewer, +pub fn CodeEditorWidget::reveal_lines_near_top( + self : CodeEditorWidget, start_line_number : Int, end_line_number : Int, ) -> Unit { @@ -335,8 +346,8 @@ pub fn Viewer::reveal_lines_near_top( ///| /// Reveals a model line span `[start, end]` (1-based) centered only if it is /// currently outside the viewport (`CenterIfOutsideViewport`). -pub fn Viewer::reveal_lines_in_center_if_outside_viewport( - self : Viewer, +pub fn CodeEditorWidget::reveal_lines_in_center_if_outside_viewport( + self : CodeEditorWidget, start_line_number : Int, end_line_number : Int, ) -> Unit { @@ -349,8 +360,8 @@ pub fn Viewer::reveal_lines_in_center_if_outside_viewport( ///| /// Reveals a 1-based model position centered (`Center`), horizontally too. -pub fn Viewer::reveal_position_in_center( - self : Viewer, +pub fn CodeEditorWidget::reveal_position_in_center( + self : CodeEditorWidget, position : @base_common.Position, ) -> Unit { self.reveal_position_with(position, Center) @@ -358,8 +369,8 @@ pub fn Viewer::reveal_position_in_center( ///| /// Reveals a 1-based model position near the top (`NearTop`), horizontally too. -pub fn Viewer::reveal_position_near_top( - self : Viewer, +pub fn CodeEditorWidget::reveal_position_near_top( + self : CodeEditorWidget, position : @base_common.Position, ) -> Unit { self.reveal_position_with(position, NearTop) @@ -368,14 +379,17 @@ pub fn Viewer::reveal_position_near_top( ///| /// Reveals a model `Range`, aligning its top (`Simple`) and revealing it /// horizontally. -pub fn Viewer::reveal_range(self : Viewer, range : @base_common.Range) -> Unit { +pub fn CodeEditorWidget::reveal_range( + self : CodeEditorWidget, + range : @base_common.Range, +) -> Unit { self.send_reveal_range(range, Simple, reveal_horizontal=true) } ///| /// Reveals a model `Range` centered (`Center`), horizontally too. -pub fn Viewer::reveal_range_in_center( - self : Viewer, +pub fn CodeEditorWidget::reveal_range_in_center( + self : CodeEditorWidget, range : @base_common.Range, ) -> Unit { self.send_reveal_range(range, Center, reveal_horizontal=true) @@ -384,8 +398,8 @@ pub fn Viewer::reveal_range_in_center( ///| /// Reveals a model `Range` centered only if it is currently outside the /// viewport (`CenterIfOutsideViewport`), horizontally too. -pub fn Viewer::reveal_range_in_center_if_outside_viewport( - self : Viewer, +pub fn CodeEditorWidget::reveal_range_in_center_if_outside_viewport( + self : CodeEditorWidget, range : @base_common.Range, ) -> Unit { self.send_reveal_range(range, CenterIfOutsideViewport, reveal_horizontal=true) @@ -393,8 +407,8 @@ pub fn Viewer::reveal_range_in_center_if_outside_viewport( ///| /// Reveals a model `Range` near the top (`NearTop`), horizontally too. -pub fn Viewer::reveal_range_near_top( - self : Viewer, +pub fn CodeEditorWidget::reveal_range_near_top( + self : CodeEditorWidget, range : @base_common.Range, ) -> Unit { self.send_reveal_range(range, NearTop, reveal_horizontal=true) @@ -403,8 +417,8 @@ pub fn Viewer::reveal_range_near_top( ///| /// Reveals a model `Range` near the top only if it is currently outside the /// viewport (`NearTopIfOutsideViewport`), horizontally too. -pub fn Viewer::reveal_range_near_top_if_outside_viewport( - self : Viewer, +pub fn CodeEditorWidget::reveal_range_near_top_if_outside_viewport( + self : CodeEditorWidget, range : @base_common.Range, ) -> Unit { self.send_reveal_range( @@ -417,8 +431,8 @@ pub fn Viewer::reveal_range_near_top_if_outside_viewport( ///| /// Reveals a model `Range` at the very top of the viewport (`Top`), /// horizontally too. -pub fn Viewer::reveal_range_at_top( - self : Viewer, +pub fn CodeEditorWidget::reveal_range_at_top( + self : CodeEditorWidget, range : @base_common.Range, ) -> Unit { self.send_reveal_range(range, Top, reveal_horizontal=true) diff --git a/editor/viewer/extends.mbt b/editor/internal/viewer/code_editor_widget/extends.mbt similarity index 69% rename from editor/viewer/extends.mbt rename to editor/internal/viewer/code_editor_widget/extends.mbt index 2e77e0bec..fcb2dbbc2 100644 --- a/editor/viewer/extends.mbt +++ b/editor/internal/viewer/code_editor_widget/extends.mbt @@ -1,9 +1,9 @@ ///| #deprecated("Use `Debug::to_repr` via the trait or `Repr(value)` instead", skip_current_package=true) #doc(hidden) -pub extend ViewerOptions with Debug::{to_repr} +pub extend CodeEditorOptions with Debug::{to_repr} ///| #deprecated("Use `Debug::to_repr` via the trait or `Repr(value)` instead", skip_current_package=true) #doc(hidden) -pub extend ViewerViewState with Debug::{to_repr} +pub extend CodeEditorViewState with Debug::{to_repr} diff --git a/editor/viewer/folding_contribution.mbt b/editor/internal/viewer/code_editor_widget/folding_contribution.mbt similarity index 91% rename from editor/viewer/folding_contribution.mbt rename to editor/internal/viewer/code_editor_widget/folding_contribution.mbt index 898e31c58..fd3879de2 100644 --- a/editor/viewer/folding_contribution.mbt +++ b/editor/internal/viewer/code_editor_widget/folding_contribution.mbt @@ -11,6 +11,7 @@ fn register_folding_contribution(registry : EditorContributionRegistry) -> Unit { registry.register_editor_contribution( @folding_browser.FOLDING_CONTROLLER_ID, + Folding, Eager, _viewer => Folding(FoldingController()), viewer => { @@ -70,7 +71,7 @@ fn register_folding_contribution(registry : EditorContributionRegistry) -> Unit ///| /// `CONTEXT_FOLDING_ENABLED` (`folding.ts:46`) as a live predicate. -fn Viewer::folding_enabled(self : Viewer) -> Bool { +fn CodeEditorWidget::folding_enabled(self : CodeEditorWidget) -> Bool { self.is_code_presentation() && self.get_options().folding && self.folding_controller() is Some(controller) && @@ -79,14 +80,16 @@ fn Viewer::folding_enabled(self : Viewer) -> Bool { ///| /// `FoldingAction.getSelectedLines` (`folding.ts:571`). -fn Viewer::folding_selected_lines(self : Viewer) -> Array[Int] { +fn CodeEditorWidget::folding_selected_lines( + self : CodeEditorWidget, +) -> Array[Int] { self.get_selections().unwrap_or([]).map(s => s.start().line_number) } ///| /// The `FoldingAction.runEditorCommand` tail (`folding.ts:562-566`): after /// the state change, keep the selection's start on screen. -fn Viewer::folding_reveal_selection(self : Viewer) -> Unit { +fn CodeEditorWidget::folding_reveal_selection(self : CodeEditorWidget) -> Unit { match self.get_selection() { Some(selection) => self.folding_reveal(selection.start()) None => () @@ -97,7 +100,7 @@ fn Viewer::folding_reveal_selection(self : Viewer) -> Unit { /// `FoldAction.invoke`'s no-arguments path (`folding.ts:770-777`): fold the /// region at each selection, or if already collapsed, the first uncollapsed /// parent instead. -fn Viewer::folding_fold_action(self : Viewer) -> Unit { +fn CodeEditorWidget::folding_fold_action(self : CodeEditorWidget) -> Unit { guard self.folding_controller() is Some(controller) else { return } guard controller.folding_model is Some(folding_model) else { return } @folding_browser.set_collapse_state_up( @@ -111,7 +114,7 @@ fn Viewer::folding_fold_action(self : Viewer) -> Unit { ///| /// `UnfoldAction.invoke`'s no-arguments path (`folding.ts:688-695`): unfold /// one level at each selection. -fn Viewer::folding_unfold_action(self : Viewer) -> Unit { +fn CodeEditorWidget::folding_unfold_action(self : CodeEditorWidget) -> Unit { guard self.folding_controller() is Some(controller) else { return } guard controller.folding_model is Some(folding_model) else { return } @folding_browser.set_collapse_state_levels_down( diff --git a/editor/viewer/folding_host.mbt b/editor/internal/viewer/code_editor_widget/folding_host.mbt similarity index 92% rename from editor/viewer/folding_host.mbt rename to editor/internal/viewer/code_editor_widget/folding_host.mbt index b7148cd45..016de8836 100644 --- a/editor/viewer/folding_host.mbt +++ b/editor/internal/viewer/code_editor_widget/folding_host.mbt @@ -1,9 +1,9 @@ -// The `Viewer::` half of Monaco's `FoldingController` +// The `CodeEditorWidget::` half of Monaco's `FoldingController` // (`vscode/src/vs/editor/contrib/folding/browser/folding.ts`, vscode // checked-in submodule): the // event-handler bodies that need the editor. The controller *state* is owned -// by the Viewer's central contribution entry; editor-facing glue stays beside -// the root-owned Viewer state so the folding implementation remains below the +// by the CodeEditorWidget's central contribution entry; editor-facing glue stays beside +// the root-owned CodeEditorWidget state so the folding implementation remains below the // public facade. // // Deviations beyond the contrib file's recorded omissions: @@ -15,8 +15,8 @@ ///| /// The typed central-registry lookup backing Monaco's /// `FoldingController.get`. -fn Viewer::folding_controller( - self : Viewer, +fn CodeEditorWidget::folding_controller( + self : CodeEditorWidget, ) -> @folding_browser.FoldingController? { match self.contributions.lookup(@folding_browser.FOLDING_CONTROLLER_ID) { Some(entry) => @@ -39,7 +39,7 @@ fn Viewer::folding_controller( /// delimiter visible. Top-level runs of plain `//` comment lines — typically /// copyright headers — collapse to their first line. The call does nothing /// when folding is disabled or no folding model is attached. -pub fn Viewer::fold_top_level(self : Viewer) -> Unit { +pub fn CodeEditorWidget::fold_top_level(self : CodeEditorWidget) -> Unit { guard self.is_code_presentation() else { return } guard self.get_options().folding else { return } guard self.folding_controller() is Some(controller) else { return } @@ -60,7 +60,7 @@ pub fn Viewer::fold_top_level(self : Viewer) -> Unit { ///| /// `onModelChanged` (`folding.ts:227`). -fn Viewer::folding_on_model_changed(self : Viewer) -> Unit { +fn CodeEditorWidget::folding_on_model_changed(self : CodeEditorWidget) -> Unit { guard self.folding_controller() is Some(controller) else { return } controller.clear_local() guard self.is_code_presentation() else { return } @@ -93,9 +93,9 @@ fn Viewer::folding_on_model_changed(self : Viewer) -> Unit { ///| /// The `onDidChangeConfiguration` cluster (`folding.ts:155-180`), reduced to /// the option axes the viewer carries. -fn Viewer::folding_on_configuration_changed( - self : Viewer, - previous : ViewerOptions, +fn CodeEditorWidget::folding_on_configuration_changed( + self : CodeEditorWidget, + previous : CodeEditorOptions, ) -> Unit { let options = self.get_options() if previous.folding != options.folding || @@ -125,8 +125,8 @@ fn Viewer::folding_on_configuration_changed( ///| /// `getRangeProvider` (`folding.ts:280`), indent strategy only. -fn Viewer::folding_range_provider( - self : Viewer, +fn CodeEditorWidget::folding_range_provider( + self : CodeEditorWidget, controller : @folding_browser.FoldingController, editor_model : @model.TextModel, ) -> @folding_browser.IndentRangeProvider { @@ -151,7 +151,9 @@ fn Viewer::folding_range_provider( ///| /// `triggerFoldingModelChanged` (`folding.ts:305`), synchronous: computes /// the provider ranges and reconciles them into the folding model. -fn Viewer::trigger_folding_model_changed(self : Viewer) -> Unit { +fn CodeEditorWidget::trigger_folding_model_changed( + self : CodeEditorWidget, +) -> Unit { guard self.folding_controller() is Some(controller) else { return } guard controller.folding_model is Some(folding_model) else { return } let provider = self.folding_range_provider( @@ -181,8 +183,8 @@ fn Viewer::trigger_folding_model_changed(self : Viewer) -> Unit { ///| /// `onHiddenRangesChanges` (`folding.ts:352`). -fn Viewer::folding_on_hidden_ranges_changes( - self : Viewer, +fn CodeEditorWidget::folding_on_hidden_ranges_changes( + self : CodeEditorWidget, hidden_ranges : Array[@base_common.Range], ) -> Unit { guard self.folding_controller() is Some(controller) else { return } @@ -206,7 +208,9 @@ fn Viewer::folding_on_hidden_ranges_changes( /// `onCursorPositionChanged` + `revealCursor` (`folding.ts:364-394`), /// without the source's 200ms scheduler: a cursor that lands in a hidden /// range unfolds the regions hiding it. -fn Viewer::folding_on_cursor_position_changed(self : Viewer) -> Unit { +fn CodeEditorWidget::folding_on_cursor_position_changed( + self : CodeEditorWidget, +) -> Unit { guard self.folding_controller() is Some(controller) else { return } guard controller.hidden_range_model is Some(hidden_range_model) else { return @@ -237,8 +241,8 @@ fn Viewer::folding_on_cursor_position_changed(self : Viewer) -> Unit { ///| /// `reveal` (`folding.ts:512`). -fn Viewer::folding_reveal( - self : Viewer, +fn CodeEditorWidget::folding_reveal( + self : CodeEditorWidget, position : @base_common.Position, ) -> Unit { self.reveal_position_in_center_if_outside_viewport(position) @@ -248,8 +252,8 @@ fn Viewer::folding_reveal( /// `onEditorMouseDown` (`folding.ts:396-447`). Targets arrive model-space /// (the public-event boundary converts), so `e.target.range` is Monaco's /// `range` verbatim. -fn Viewer::folding_on_editor_mouse_down( - self : Viewer, +fn CodeEditorWidget::folding_on_editor_mouse_down( + self : CodeEditorWidget, e : @editor_browser.EditorMouseEvent, ) -> Unit { guard self.folding_controller() is Some(controller) else { return } @@ -317,8 +321,8 @@ fn Viewer::folding_on_editor_mouse_down( /// unreachable modifier variants (the reduced event carried no modifiers): /// alt-click folds/unfolds the surrounding regions, middle- or shift-click /// toggles recursively. -fn Viewer::folding_on_editor_mouse_up( - self : Viewer, +fn CodeEditorWidget::folding_on_editor_mouse_up( + self : CodeEditorWidget, e : @editor_browser.EditorMouseEvent, ) -> Unit { guard self.folding_controller() is Some(controller) else { return } diff --git a/editor/viewer/folding_host_wbtest.mbt b/editor/internal/viewer/code_editor_widget/folding_host_wbtest.mbt similarity index 96% rename from editor/viewer/folding_host_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/folding_host_wbtest.mbt index d963cb1be..9bd9362b1 100644 --- a/editor/viewer/folding_host_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/folding_host_wbtest.mbt @@ -1,6 +1,6 @@ // Headless end-to-end tests for the folding contribution: the // `FoldingController` chain from `onModelChanged` through the hidden-range -// model into `Viewer::set_hidden_areas` and the view axis. +// model into `CodeEditorWidget::set_hidden_areas` and the view axis. ///| test "folding: contribution computes indent regions on model attach" { @@ -80,7 +80,7 @@ test "folding: MoonBit outline keeps the complete signature and opening brace" { #| use(display_param) #|} #|tail - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model(Some(test_model(text, language_id="moonbit"))) viewer.set_position(Position(4, 3)) viewer.fold_top_level() @@ -436,7 +436,7 @@ test "folding: same-start provider ranges yield to the comment run" { #| // one #| two #|tail - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model(Some(test_model(text, language_id="moonbit"))) viewer.fold_top_level() guard viewer.folding_controller() is Some(controller) else { @@ -462,7 +462,7 @@ test "folding: provider ranges containing a comment run are retained" { #| // indent a #| // indent b #|tail - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model(Some(test_model(text, language_id="moonbit"))) viewer.fold_top_level() guard viewer.folding_controller() is Some(controller) else { @@ -511,7 +511,7 @@ test "folding: MoonBit outline collapses a copyright header to its first line" { #|fn f() -> Int { #| 1 #|} - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model(Some(test_model(text, language_id="moonbit"))) viewer.fold_top_level() let view_model = viewer.test_view_model() @@ -557,7 +557,7 @@ test "folding: MoonBit outline collapses a copyright header to its first line" { ///| test "folding: comment-only MoonBit file folds and survives option updates" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model( Some( test_model( @@ -577,7 +577,7 @@ test "folding: comment-only MoonBit file folds and survives option updates" { assert_eq(viewer.test_view_model().line_count(), 1) // ...and provider recomputation (an option change) retains the host fold // even though the file contributes no outline function bodies. - viewer.update_options(ViewerOptions(show_folding_controls=Never)) + viewer.update_options(CodeEditorOptions(show_folding_controls=Never)) guard viewer.folding_controller() is Some(controller) else { fail("expected a folding controller") } @@ -607,7 +607,7 @@ test "folding: formatted modifiers and impl headers remain visible" { #| true #| } #|tail - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model(Some(test_model(text, language_id="moonbit"))) viewer.fold_top_level() let view_model = viewer.test_view_model() @@ -628,7 +628,7 @@ test "folding: formatted modifiers and impl headers remain visible" { ///| test "folding: repeated MoonBit outline fold is idempotent" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model( Some( test_model( @@ -653,7 +653,7 @@ test "folding: repeated MoonBit outline fold is idempotent" { ///| test "folding: MoonBit outline survives folding decoration option updates" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model( Some( test_model( @@ -674,7 +674,7 @@ test "folding: MoonBit outline survives folding decoration option updates" { guard controller.folding_model is Some(folding_model) else { fail("expected an attached folding model") } - viewer.update_options(ViewerOptions(show_folding_controls=Never)) + viewer.update_options(CodeEditorOptions(show_folding_controls=Never)) let regions = folding_model.regions() let fold_regions = folding_model.get_regions_inside(None) assert_eq(regions.length(), 1) @@ -703,7 +703,9 @@ test "folding: MoonBit outline survives folding decoration option updates" { ///| test "folding: MoonBit outline respects the configured folding limit" { - let viewer = Viewer::Viewer(options=ViewerOptions(folding_maximum_regions=1)) + let viewer = CodeEditorWidget::CodeEditorWidget( + options=CodeEditorOptions(folding_maximum_regions=1), + ) viewer.set_model( Some( test_model( @@ -745,7 +747,7 @@ test "folding: MoonBit outline preserves signatures with same-line bodies" { #| display_param : X, #|) -> ReturnType { display_param } #|tail - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model(Some(test_model(text, language_id="moonbit"))) viewer.fold_top_level() guard viewer.folding_controller() is Some(controller) else { @@ -887,7 +889,7 @@ test "folding: disabling the option clears folds and detaches the model state" { } folding_model.toggle_collapse_state([region]) assert_eq(view_model.line_count(), 2) - viewer.update_options(ViewerOptions(folding=false)) + viewer.update_options(CodeEditorOptions(folding=false)) // `onModelChanged` with folding off: the hidden-range model's dispose // fired an empty set through the still-live listener. assert_eq(view_model.line_count(), 4) @@ -901,7 +903,7 @@ test "folding: disabling the option clears folds and detaches the model state" { ///| test "folding: model swap rebuilds state on the same central controller" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let first = test_model( ( #|a @@ -939,7 +941,7 @@ test "folding: model swap rebuilds state on the same central controller" { ///| test "folding: contribution disposal clears folds before silencing listeners" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model( Some( test_model( diff --git a/editor/viewer/hidden_areas_api.mbt b/editor/internal/viewer/code_editor_widget/hidden_areas_api.mbt similarity index 97% rename from editor/viewer/hidden_areas_api.mbt rename to editor/internal/viewer/code_editor_widget/hidden_areas_api.mbt index 4c3fb9ebe..4df7b9368 100644 --- a/editor/viewer/hidden_areas_api.mbt +++ b/editor/internal/viewer/code_editor_widget/hidden_areas_api.mbt @@ -9,8 +9,8 @@ /// merge rather than clobber each other. A changed view-line mapping /// re-flushes the layout and emits Monaco's typed `ViewFlushedEvent`/ /// `ViewLineMappingChangedEvent` fan-out at the source. -fn Viewer::set_hidden_areas( - self : Viewer, +fn CodeEditorWidget::set_hidden_areas( + self : CodeEditorWidget, ranges : Array[@base_common.Range], source? : String = "", force_update? : Bool = false, diff --git a/editor/viewer/hidden_areas_changed_event_wbtest.mbt b/editor/internal/viewer/code_editor_widget/hidden_areas_changed_event_wbtest.mbt similarity index 97% rename from editor/viewer/hidden_areas_changed_event_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/hidden_areas_changed_event_wbtest.mbt index e6cbdc118..75e04d783 100644 --- a/editor/viewer/hidden_areas_changed_event_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/hidden_areas_changed_event_wbtest.mbt @@ -37,7 +37,7 @@ test "headless hidden-area event is changed-only" { ///| test "hidden-area event is a no-op without a model" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() defer viewer.dispose() let mut delivered = 0 viewer.on_did_change_hidden_areas(() => delivered += 1) |> ignore @@ -51,7 +51,7 @@ test "hidden-area event follows only the current model across a swap" { defer first.dispose() let second = test_model(hidden_event_source) defer second.dispose() - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() defer viewer.dispose() let delivered_values : Array[String] = [] viewer.on_did_change_hidden_areas(() => { @@ -77,7 +77,7 @@ test "hidden-area event follows only the current model across a swap" { test "viewer disposal retires hidden-area public and model-scoped delivery" { let model = test_model(hidden_event_source) defer model.dispose() - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() viewer.set_model(Some(model)) let view_model = viewer.test_view_model() let mut delivered = 0 diff --git a/editor/viewer/hover_contribution.mbt b/editor/internal/viewer/code_editor_widget/hover_contribution.mbt similarity index 94% rename from editor/viewer/hover_contribution.mbt rename to editor/internal/viewer/code_editor_widget/hover_contribution.mbt index 067a344ee..aba0a3b60 100644 --- a/editor/viewer/hover_contribution.mbt +++ b/editor/internal/viewer/code_editor_widget/hover_contribution.mbt @@ -1,7 +1,7 @@ // The hover feature's registrations — the viewer's // `vscode/src/vs/editor/contrib/hover/browser/hoverContribution.ts` + // `hoverActions.ts` (scroll cluster) + `hoverActionIds.ts`. The controller -// state lives in the Viewer's central contribution entry, and the registry +// state lives in the CodeEditorWidget's central contribution entry, and the registry // also inverts command/keyboard dispatch: the hover widget's keydown resolves // through the keybinding table instead of an inline key map. @@ -12,6 +12,7 @@ fn register_hover_contribution(registry : EditorContributionRegistry) -> Unit { registry.register_editor_contribution( "editor.contrib.contentHover", + ContentHover, BeforeFirstInteraction, _viewer => ContentHover(ContentHoverContributionState()), _viewer => [], @@ -81,8 +82,8 @@ fn register_hover_contribution(registry : EditorContributionRegistry) -> Unit { /// Runs `f` on the persistent content-hover widget, the run-body plumbing of /// `ContentHoverController.get(editor)?.scrollUp()`, with the source lookup /// represented locally by the typed central contribution accessor. -fn Viewer::with_hover_widget( - self : Viewer, +fn CodeEditorWidget::with_hover_widget( + self : CodeEditorWidget, f : (@hover_browser.ContentHoverWidget) -> Unit, ) -> Unit { match self.content_hover_contribution() { @@ -98,7 +99,7 @@ fn Viewer::with_hover_widget( ///| /// The `EditorContextKeys.hoverFocused` context key as a live predicate: /// whether the document's active element sits inside the hover widget. -fn Viewer::is_hover_focused(self : Viewer) -> Bool { +fn CodeEditorWidget::is_hover_focused(self : CodeEditorWidget) -> Bool { match self.content_hover_contribution() { Some(state) => match state.widget { diff --git a/editor/viewer/initialization_order_reference_wbtest.mbt b/editor/internal/viewer/code_editor_widget/initialization_order_reference_wbtest.mbt similarity index 94% rename from editor/viewer/initialization_order_reference_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/initialization_order_reference_wbtest.mbt index ada432732..09ab2291b 100644 --- a/editor/viewer/initialization_order_reference_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/initialization_order_reference_wbtest.mbt @@ -39,8 +39,8 @@ test "headless model attach waits for explicit initialization" { InitializationCountingTokenizer::{ 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( @@ -70,7 +70,7 @@ test "initialization reentry does not initialize a replacement model" { InitializationCountingTokenizer::{ calls: replacement_calls }, ) defer replacement_registration.dispose() - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() defer viewer.dispose() let first = test_model( long_document(4), diff --git a/editor/viewer/input.mbt b/editor/internal/viewer/code_editor_widget/input.mbt similarity index 93% rename from editor/viewer/input.mbt rename to editor/internal/viewer/code_editor_widget/input.mbt index 8ca316297..c76d299fc 100644 --- a/editor/viewer/input.mbt +++ b/editor/internal/viewer/code_editor_widget/input.mbt @@ -7,8 +7,8 @@ /// local analog of Monaco's `View._createPointerHandlerHelper`). Copy, /// keyboard, focus tracking, wheel and the scrollbar/browser-reveal cluster /// stay here. -fn Viewer::hook_view_input( - self : Viewer, +fn CodeEditorWidget::hook_view_input( + self : CodeEditorWidget, view : @view.View, ) -> @controller.MouseHandler { let handler = @controller.MouseHandler(self.pointer_handler_helper(view)) @@ -136,8 +136,8 @@ fn add_view_event_listener( /// suppressed). Exact chord matching leaves unregistered Alt/CtrlCmd variants /// to the browser. A cursor command whose model-bound precondition wins stays /// handled even when it reaches a document boundary and changes no state. -fn Viewer::handle_editor_key_down( - self : Viewer, +fn CodeEditorWidget::handle_editor_key_down( + self : CodeEditorWidget, key : String, alt~ : Bool, ctrl_cmd~ : Bool, @@ -164,7 +164,7 @@ fn remove_element_if_contained( /// `FontMeasurements.readFontInfo` when a DOM view exists (Monaco's /// `_readConfiguration`), the headless estimate otherwise. A changed line /// height re-flows the layout; the view root is restyled in place. -fn Viewer::refresh_font_info(self : Viewer) -> Unit { +fn CodeEditorWidget::refresh_font_info(self : CodeEditorWidget) -> Unit { let bare = self.configuration.snapshot().options.bare_font_info( pixel_ratio=@base_browser.device_pixel_ratio(), ) @@ -191,7 +191,10 @@ fn Viewer::refresh_font_info(self : Viewer) -> Unit { /// consume the font through the `--editor-*` custom properties /// (`view_lines.css` / `margin.css` / `theme.css`), so those are refreshed /// alongside the inline font style. -fn Viewer::apply_font_info_to_view(self : Viewer, view : @view.View) -> Unit { +fn CodeEditorWidget::apply_font_info_to_view( + self : CodeEditorWidget, + view : @view.View, +) -> Unit { let bare = self.configuration.snapshot().font_info.bare() @browser_config.apply_font_info(view.root, bare) @base_browser.set_style_property( @@ -214,8 +217,8 @@ fn Viewer::apply_font_info_to_view(self : Viewer, view : @view.View) -> Unit { ///| /// Monaco `ElementSizeObserver.measureReferenceDomElement`: read the host's /// untransformed client box and clamp both axes to the source's 5px minimum. -/// This helper is also used before `Viewer` construction publishes its -/// configuration, so it deliberately has no `Viewer` dependency. +/// This helper is also used before `CodeEditorWidget` construction publishes its +/// configuration, so it deliberately has no `CodeEditorWidget` dependency. fn read_container_size(container : @rdom.Element) -> (Double, Double) { let width = @base_browser.element_client_width(container) let height = @base_browser.element_client_height(container) @@ -227,7 +230,7 @@ fn read_container_size(container : @rdom.Element) -> (Double, Double) { /// the derived gutter width feed the layout model and every later hit /// test. DOM scroll offsets are treated only as browser reveal deltas; /// `ViewLayout` owns the editor scroll truth. -fn Viewer::measure(self : Viewer) -> Unit { +fn CodeEditorWidget::measure(self : CodeEditorWidget) -> Unit { let container = match self.mount { Headless => return Mounted(mounted) => mounted.container @@ -276,10 +279,10 @@ fn Viewer::measure(self : Viewer) -> Unit { } ///| -fn Viewer::sync_content_width(self : Viewer) -> Unit { +fn CodeEditorWidget::sync_content_width(self : CodeEditorWidget) -> Unit { guard self.get_view_model() is Some(view_model) else { return } // Rendered ViewLines now owns the measured maximum and feeds it into - // ViewLayout. The Viewer only supplies the wrapping policy; logical UTF-16 + // ViewLayout. The CodeEditorWidget only supplies the wrapping policy; logical UTF-16 // length must never stand in for browser geometry. view_model.view_layout.set_is_viewport_wrapping( self.configuration.snapshot().word_wrap(), diff --git a/editor/internal/viewer/code_editor_widget/internal_api.mbt b/editor/internal/viewer/code_editor_widget/internal_api.mbt new file mode 100644 index 000000000..f05876a5e --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/internal_api.mbt @@ -0,0 +1,56 @@ +///| +pub fn CodeEditorServices::languages( + self : CodeEditorServices, +) -> @languages.LanguageHandle { + self.languages +} + +///| +pub fn CodeEditorServices::marker_decorations( + self : CodeEditorServices, +) -> @markers.MarkerDecorationsHandle { + self.marker_decorations +} + +///| +pub fn CodeEditorServices::agent_feedback( + self : CodeEditorServices, +) -> @agent_feedback_api.AgentFeedbackHandle { + self.agent_feedback +} + +///| +pub fn CodeEditorServices::location_opener( + self : CodeEditorServices, +) -> @navigation_api.LocationOpenerHandle? { + self.location_opener +} + +///| +pub fn CodeEditorServices::text_model_resolver( + self : CodeEditorServices, +) -> @navigation_api.TextModelResolverHandle? { + self.text_model_resolver +} + +///| +pub fn CodeEditorServices::quick_diff( + self : CodeEditorServices, +) -> @quick_diff_api.QuickDiffHandle { + self.quick_diff +} + +///| +pub fn CodeEditorServices::log_service( + self : CodeEditorServices, +) -> @log.LogHandle { + self.log_service +} + +///| +/// Internal lifecycle fact used by composite widgets to verify that their +/// owned kernels have completed teardown. Public Viewer does not forward this +/// diagnostic surface. +pub fn CodeEditorWidget::is_disposed(self : CodeEditorWidget) -> Bool { + self.disposed +} diff --git a/editor/viewer/layout_content_width.mbt b/editor/internal/viewer/code_editor_widget/layout_content_width.mbt similarity index 88% rename from editor/viewer/layout_content_width.mbt rename to editor/internal/viewer/code_editor_widget/layout_content_width.mbt index 4b9fc8445..8802c9a3a 100644 --- a/editor/viewer/layout_content_width.mbt +++ b/editor/internal/viewer/code_editor_widget/layout_content_width.mbt @@ -2,7 +2,7 @@ /// Keeps the measured editor outer width invariant when a changed model-line /// digit band moves `layoutInfo.contentLeft`. Monaco's /// `EditorConfiguration.setModelLineCount` recomputes layout options before it -/// publishes the resulting Configuration event. The local Viewer retains the +/// publishes the resulting Configuration event. The local CodeEditorWidget retains the /// last measured outer client width as its source fact, so it can derive the /// new content width synchronously without another DOM read: /// @@ -11,8 +11,8 @@ /// Returns whether the content-left band changed. The caller emits LayoutInfo /// only after this helper, so ContentWidgets' live configuration read observes /// the matching `contentLeft`/`contentWidth` pair. -fn Viewer::sync_content_box_for_model_line_count( - self : Viewer, +fn CodeEditorWidget::sync_content_box_for_model_line_count( + self : CodeEditorWidget, view_model : @view_model.ViewModel, current_model_line_count : Int, ) -> Bool { diff --git a/editor/viewer/layout_content_width_wbtest.mbt b/editor/internal/viewer/code_editor_widget/layout_content_width_wbtest.mbt similarity index 94% rename from editor/viewer/layout_content_width_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/layout_content_width_wbtest.mbt index ca80c7cf8..9ec72e465 100644 --- a/editor/viewer/layout_content_width_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/layout_content_width_wbtest.mbt @@ -2,7 +2,7 @@ test "model-line digit band preserves measured outer width before LayoutInfo" { with_test_viewer(["1", "2", "3", "4", "5", "6", "7", "8", "9"].join("\n"), viewer => { viewer.update_options( - ViewerOptions(line_numbers_min_chars=1, folding=false), + CodeEditorOptions(line_numbers_min_chars=1, folding=false), ) viewer.test_set_viewport(width=360.0, height=180.0) let view_model = viewer.test_view_model() diff --git a/editor/viewer/lifecycle_ownership_wbtest.mbt b/editor/internal/viewer/code_editor_widget/lifecycle_ownership_wbtest.mbt similarity index 90% rename from editor/viewer/lifecycle_ownership_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/lifecycle_ownership_wbtest.mbt index ec85ef758..e734ca209 100644 --- a/editor/viewer/lifecycle_ownership_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/lifecycle_ownership_wbtest.mbt @@ -29,7 +29,7 @@ fn live_lifecycle_marker_count( /// Caller-retained concrete backings for root white-box tests. Production /// receives only the narrowed handles captured by `services`. struct RootTestServiceBackings { - services : ViewerServices + services : CodeEditorServices languages : @languages.Languages markers : @markers.MarkerService marker_decorations : @markers.MarkerDecorationsService @@ -50,7 +50,7 @@ fn RootTestServiceBackings::new() -> RootTestServiceBackings { ) let agent_feedback = @agent_feedback.AgentFeedbackService() let quick_diff = @quick_diff.QuickDiffService() - let services = ViewerServices( + let services = CodeEditorServices( languages=languages.language_handle(log_service.log_handle()), markers=Decorations(marker_decorations.marker_decorations_handle()), agent_feedback=agent_feedback.agent_feedback_handle(), @@ -80,7 +80,7 @@ fn RootTestServiceBackings::dispose(self : RootTestServiceBackings) -> Unit { ///| test "model slot generation and browser shape follow headless lifecycle" { - let viewer = Viewer::Viewer() + let viewer = CodeEditorWidget::CodeEditorWidget() let model = test_model("alpha") assert_eq(viewer.model_slot.generation, 0) assert_true(viewer.model_slot.current is None) @@ -116,7 +116,7 @@ test "model slot generation and browser shape follow headless lifecycle" { ///| test "preloaded markers survive detach and seed again on reattach" { let backings = RootTestServiceBackings::new() - let viewer = Viewer::Viewer(services=backings.services) + let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services) let model = test_model("alpha") seed_lifecycle_marker(backings.markers, model) viewer.set_model(Some(model)) @@ -136,8 +136,8 @@ test "preloaded markers survive detach and seed again on reattach" { ///| test "set_value refresh does not acquire another shared-model lease" { let backings = RootTestServiceBackings::new() - let first = Viewer::Viewer(services=backings.services) - let second = Viewer::Viewer(services=backings.services) + let first = CodeEditorWidget::CodeEditorWidget(services=backings.services) + let second = CodeEditorWidget::CodeEditorWidget(services=backings.services) let model = test_model("alpha") seed_lifecycle_marker(backings.markers, model) first.set_model(Some(model)) @@ -160,8 +160,12 @@ test "set_value refresh does not acquire another shared-model lease" { ///| test "distinct same-URI models retain independent decoration owners" { let backings = RootTestServiceBackings::new() - let first_viewer = Viewer::Viewer(services=backings.services) - let second_viewer = Viewer::Viewer(services=backings.services) + let first_viewer = CodeEditorWidget::CodeEditorWidget( + services=backings.services, + ) + let second_viewer = CodeEditorWidget::CodeEditorWidget( + services=backings.services, + ) let first_model = test_model("first") let second_model = test_model("second") assert_true(first_model.uri.to_string() == second_model.uri.to_string()) @@ -185,8 +189,8 @@ test "distinct same-URI models retain independent decoration owners" { ///| test "model disposal detaches two Viewers sharing one registration" { let backings = RootTestServiceBackings::new() - let first = Viewer::Viewer(services=backings.services) - let second = Viewer::Viewer(services=backings.services) + let first = CodeEditorWidget::CodeEditorWidget(services=backings.services) + let second = CodeEditorWidget::CodeEditorWidget(services=backings.services) let model = test_model("alpha") seed_lifecycle_marker(backings.markers, model) first.set_model(Some(model)) @@ -202,9 +206,9 @@ test "model disposal detaches two Viewers sharing one registration" { } ///| -test "explicit services stay borrowed after Viewer disposal" { +test "explicit services stay borrowed after CodeEditorWidget disposal" { let backings = RootTestServiceBackings::new() - let viewer = Viewer::Viewer(services=backings.services) + let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services) assert_false(viewer.owns_services) viewer.dispose() assert_false(backings.services.disposed) @@ -221,7 +225,7 @@ test "explicit services stay borrowed after Viewer disposal" { ///| test "MarkerStore makes only its generated decorations bundle-owned" { let backings = RootTestServiceBackings::new() - let services = ViewerServices( + let services = CodeEditorServices( languages=backings.languages.language_handle( backings.log_service.log_handle(), ), @@ -264,7 +268,7 @@ test "Decorations leaves every caller handle live after bundle disposal" { let marker_handle = backings.marker_decorations.marker_decorations_handle() let feedback_handle = backings.agent_feedback.agent_feedback_handle() let quick_diff_handle = backings.quick_diff.quick_diff_handle() - let services = ViewerServices( + let services = CodeEditorServices( languages=language_handle, markers=Decorations(marker_handle), agent_feedback=feedback_handle, @@ -329,7 +333,7 @@ async test "navigation handles stay optional and borrowed after bundle disposal" None } }) - let services = ViewerServices( + let services = CodeEditorServices( location_opener=opener, text_model_resolver=resolver, ) @@ -345,7 +349,7 @@ async test "navigation handles stay optional and borrowed after bundle disposal" assert_true(opener.open(request)) guard resolver.resolve(target, @language.CancellationToken::none()) is Some(reference) else { - fail("borrowed resolver stopped after ViewerServices disposal") + fail("borrowed resolver stopped after CodeEditorServices disposal") } assert_true(reference.model().same(model)) reference.dispose() @@ -355,15 +359,15 @@ async test "navigation handles stay optional and borrowed after bundle disposal" assert_false(model.is_disposed()) model.dispose() - let defaults = ViewerServices() + let defaults = CodeEditorServices() assert_true(defaults.location_opener is None) assert_true(defaults.text_model_resolver is None) defaults.dispose() } ///| -test "omitted services are owned and Viewer disposal is idempotent" { - let viewer = Viewer::Viewer() +test "omitted services are owned and CodeEditorWidget disposal is idempotent" { + let viewer = CodeEditorWidget::CodeEditorWidget() let services = viewer.services let owned_markers = services.owned_markers.unwrap() let owned_marker_decorations = services.owned_marker_decorations.unwrap() @@ -405,14 +409,14 @@ test "omitted services are owned and Viewer disposal is idempotent" { assert_true(lifetime_store_alive_in_event.val) assert_true(model_detached_in_event.val) assert_eq(viewer.lifetime_disposables.length(), 0) - // A never-attached Viewer remains semantically headless through disposal: + // A never-attached CodeEditorWidget remains semantically headless through disposal: // it has no browser frame, focus, host, or placeholder capability to tear // down. assert_true(viewer.mount is Headless) assert_false(viewer.mount.has_pending_render()) assert_false(viewer.has_text_focus()) assert_false(model.is_disposed()) - // The owning Viewer released all three bundle-owned default backings. Their + // The owning CodeEditorWidget released all three bundle-owned default backings. Their // captured concrete references remain safe but have no live ingress. seed_lifecycle_marker(owned_markers, model) owned_agent_feedback.set_feedback_enabled(model.uri, true) @@ -436,9 +440,9 @@ test "omitted services are owned and Viewer disposal is idempotent" { } ///| -test "disposed headless Viewer ignores later model and callback work" { +test "disposed headless CodeEditorWidget ignores later model and callback work" { let backings = RootTestServiceBackings::new() - let viewer = Viewer::Viewer(services=backings.services) + let viewer = CodeEditorWidget::CodeEditorWidget(services=backings.services) let model = test_model("alpha") viewer.set_model(Some(model)) let mut content_events = 0 @@ -458,8 +462,8 @@ test "disposed headless Viewer ignores later model and callback work" { } ///| -test "headless Viewer detaches its model on disposal" { - let viewer = Viewer::Viewer() +test "headless CodeEditorWidget detaches its model on disposal" { + let viewer = CodeEditorWidget::CodeEditorWidget() assert_true(viewer.get_model() is None) let model = test_model("testing-registration") viewer.set_model(Some(model)) diff --git a/editor/viewer/markdown_comments_contribution.mbt b/editor/internal/viewer/code_editor_widget/markdown_comments_contribution.mbt similarity index 95% rename from editor/viewer/markdown_comments_contribution.mbt rename to editor/internal/viewer/code_editor_widget/markdown_comments_contribution.mbt index faf0827e0..df4c8d15c 100644 --- a/editor/viewer/markdown_comments_contribution.mbt +++ b/editor/internal/viewer/code_editor_widget/markdown_comments_contribution.mbt @@ -1,5 +1,5 @@ // Whole-line Markdown-comment contribution. Detection/normalization stays in -// the multi-target contribution package; this root file owns the Viewer-lived +// the multi-target contribution package; this root file owns the CodeEditorWidget-lived // registry entry, model-scoped ViewZones/hidden source, and reconciliation // ordering across browser/common tiers. @@ -108,7 +108,9 @@ fn MarkdownCommentContributionState::request_all_measures( } ///| -fn Viewer::sync_markdown_comment_fold_lane_width(self : Viewer) -> Unit { +fn CodeEditorWidget::sync_markdown_comment_fold_lane_width( + self : CodeEditorWidget, +) -> Unit { guard self.markdown_comment_contribution() is Some(state) else { return } let width = self.configuration.snapshot().layout_decorations_width().to_int() for _, entry in state.rendered { @@ -145,6 +147,7 @@ fn register_markdown_comment_contribution( ) -> Unit { registry.register_editor_contribution( markdown_comment_contribution_id, + MarkdownComments, AfterFirstRender, _viewer => MarkdownComments(MarkdownCommentContributionState()), viewer => { @@ -170,8 +173,8 @@ fn register_markdown_comment_contribution( } ///| -fn Viewer::markdown_comment_contribution( - self : Viewer, +fn CodeEditorWidget::markdown_comment_contribution( + self : CodeEditorWidget, ) -> MarkdownCommentContributionState? { match self.contributions.lookup(markdown_comment_contribution_id) { Some(entry) => @@ -460,7 +463,7 @@ fn render_markdown_comment_presentations( /// /// ```mermaid /// sequenceDiagram -/// participant V as Viewer +/// participant V as CodeEditorWidget /// participant P as provider or detector /// participant R as stable-key reconciler /// participant Z as ViewZones @@ -497,19 +500,17 @@ fn render_markdown_comment_presentations( /// projection -> browser /// source -> copy: model coordinates /// ``` -fn Viewer::refresh_markdown_comments( - self : Viewer, +fn CodeEditorWidget::refresh_markdown_comments( + self : CodeEditorWidget, force_update~ : Bool, ) -> Unit { guard !self.disposed else { return } - // A Peek preview is an embedded source editor, matching Monaco's preview - // surface. Whole-line documentation replacement belongs only to the outer - // Viewer; otherwise an asynchronously measured comment zone above the target - // can move the selected definition out of the tiny embedded viewport. - guard self.role == OuterViewer else { return } + // Construction policy omits this contribution from embedded Peek previews + // and diff panes. Runtime availability is therefore represented solely by + // the installed typed contribution, not by a second editor-role branch. guard self.markdown_comment_contribution() is Some(state) else { return } guard self.get_model() is Some(model) else { return } - // A headless Viewer has no replacement DOM and must keep source visible. + // A headless CodeEditorWidget has no replacement DOM and must keep source visible. guard self.current_code_browser_data() is Some(browser) else { return } let mermaid_theme = markdown_comment_mermaid_theme( self.configuration.snapshot().options.theme, @@ -703,8 +704,8 @@ fn Viewer::refresh_markdown_comments( } ///| -fn Viewer::markdown_comments_on_theme_changed( - self : Viewer, +fn CodeEditorWidget::markdown_comments_on_theme_changed( + self : CodeEditorWidget, viewer_theme : String, ) -> Unit { guard self.markdown_comment_contribution() is Some(state) else { return } @@ -719,8 +720,8 @@ fn Viewer::markdown_comments_on_theme_changed( } ///| -fn Viewer::markdown_comment_refresh_is_current( - self : Viewer, +fn CodeEditorWidget::markdown_comment_refresh_is_current( + self : CodeEditorWidget, state : MarkdownCommentContributionState, model : @model.TextModel, generation : Int, @@ -735,8 +736,8 @@ fn Viewer::markdown_comment_refresh_is_current( } ///| -fn Viewer::apply_markdown_comment_height( - self : Viewer, +fn CodeEditorWidget::apply_markdown_comment_height( + self : CodeEditorWidget, state : MarkdownCommentContributionState, key : String, expected_zone_id : String, @@ -757,7 +758,9 @@ fn Viewer::apply_markdown_comment_height( } ///| -fn Viewer::clear_markdown_comment_model_state(self : Viewer) -> Unit { +fn CodeEditorWidget::clear_markdown_comment_model_state( + self : CodeEditorWidget, +) -> Unit { guard self.markdown_comment_contribution() is Some(state) else { return } state.generation += 1 // Stop model-wide invalidation first. Per-comment content/renderer @@ -789,7 +792,7 @@ fn Viewer::clear_markdown_comment_model_state(self : Viewer) -> Unit { ///| fn MarkdownCommentContributionState::dispose( self : MarkdownCommentContributionState, - viewer : Viewer, + viewer : CodeEditorWidget, ) -> Unit { viewer.clear_markdown_comment_model_state() for listener in self.listeners { diff --git a/editor/viewer/markdown_comments_contribution_wbtest.mbt b/editor/internal/viewer/code_editor_widget/markdown_comments_contribution_wbtest.mbt similarity index 94% rename from editor/viewer/markdown_comments_contribution_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/markdown_comments_contribution_wbtest.mbt index f0fbe71cf..b7afc1c06 100644 --- a/editor/viewer/markdown_comments_contribution_wbtest.mbt +++ b/editor/internal/viewer/code_editor_widget/markdown_comments_contribution_wbtest.mbt @@ -1,4 +1,4 @@ -// DOM-free Markdown-comment policy and headless Viewer integration. The real +// DOM-free Markdown-comment policy and headless CodeEditorWidget integration. The real // projection, reconciliation, observer, and node-lifecycle contracts live in // the retained Playwright Markdown-comment suite. @@ -88,7 +88,7 @@ impl @language.MarkdownCommentProvider for MutableMarkdownContributionProvider w ///| fn markdown_contribution_state( - viewer : Viewer, + viewer : CodeEditorWidget, ) -> MarkdownCommentContributionState { viewer.markdown_comment_contribution().unwrap() } @@ -105,7 +105,7 @@ test "headless Markdown contribution never hides source without replacement DOM" MutableMarkdownContributionProvider::{ blocks: provider_blocks }, ) defer registration.dispose() - let services = ViewerServices( + let services = CodeEditorServices( languages=registry.language_handle(log_service.log_handle()), log_service=log_service.log_handle(), ) @@ -120,7 +120,7 @@ test "headless Markdown contribution never hides source without replacement DOM" ), ) defer model.dispose() - let viewer = Viewer::Viewer(services~) + let viewer = CodeEditorWidget::CodeEditorWidget(services~) defer viewer.dispose() viewer.set_model(Some(model)) assert_eq(markdown_contribution_state(viewer).rendered.length(), 0) diff --git a/editor/viewer/markdown_comments_folding_wbtest.mbt b/editor/internal/viewer/code_editor_widget/markdown_comments_folding_wbtest.mbt similarity index 100% rename from editor/viewer/markdown_comments_folding_wbtest.mbt rename to editor/internal/viewer/code_editor_widget/markdown_comments_folding_wbtest.mbt diff --git a/editor/internal/viewer/code_editor_widget/model_data.mbt b/editor/internal/viewer/code_editor_widget/model_data.mbt new file mode 100644 index 000000000..7d0dc157f --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/model_data.mbt @@ -0,0 +1,156 @@ +///| +/// CodeEditorWidget-lifetime owner of the currently attached model bundle and +/// the monotonic generation captured by swap-scoped callbacks. +priv struct ViewerModelSlot { + mut generation : Int + mut current : ModelData? +} + +///| +fn ViewerModelSlot::ViewerModelSlot() -> ViewerModelSlot { + { generation: 0, current: None } +} + +///| +fn ViewerModelSlot::begin_attach_generation(self : ViewerModelSlot) -> Int { + self.generation += 1 + self.generation +} + +///| +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) +} + +///| +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 model-scoped CodeEditorWidget ownership bundle. A mounted model +/// owns exactly one code View; headless white-box tests retain `None`. +priv struct ModelData { + model : @model.TextModel + view_model : @view_model.ViewModel + browser : CodeBrowserData? + attached_view : @model.AttachedViewImpl + listeners_to_remove : Array[@base_common.Disposable] +} + +///| +/// Complete code-presentation state for one attached model. +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 CodeBrowserData::root(self : CodeBrowserData) -> @rdom.Element { + self.view.root +} + +///| +fn CodeBrowserData::apply_theme(self : CodeBrowserData, theme : String) -> Unit { + self.view.root.set_attribute("data-theme", theme) + self.view.apply_squiggly_theme() +} + +///| +fn CodeBrowserData::focus(self : CodeBrowserData) -> Unit { + @base_browser.focus_prevent_scroll(self.view.root) +} + +///| +fn CodeBrowserData::contains_active_element(self : CodeBrowserData) -> Bool { + @base_browser.element_contains_active_element(self.view.root) +} + +///| +fn CodeEditorWidget::current_code_browser_data( + self : CodeEditorWidget, +) -> CodeBrowserData? { + match self.model_slot.current { + Some(model_data) => model_data.browser + None => None + } +} + +///| +fn CodeEditorWidget::active_presentation_root( + self : CodeEditorWidget, +) -> @rdom.Element? { + self.current_code_browser_data().map(browser => browser.root()) +} + +///| +fn ModelData::dispose( + 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() + match self.browser { + Some(browser) => browser.view.dispose() + None => () + } + self.view_model.dispose() +} diff --git a/editor/internal/viewer/code_editor_widget/moon.pkg b/editor/internal/viewer/code_editor_widget/moon.pkg new file mode 100644 index 000000000..1f871245e --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/moon.pkg @@ -0,0 +1,53 @@ +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/viewer/common/config", + "moonbitlang/editor/viewer/common/cursor", + "moonbitlang/editor/viewer/common/editor_api", + "moonbitlang/editor/viewer/common/core", + "moonbitlang/editor/viewer/common/model", + "moonbitlang/editor/viewer/common/agent_feedback_api", + "moonbitlang/editor/viewer/common/navigation_api", + "moonbitlang/editor/viewer/common/quick_diff_api", + "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/goto_symbol", + "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/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", + "moonbitlang/editor/viewer/common" @viewer_common, + "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/moonbit_outline_folding.mbt b/editor/internal/viewer/code_editor_widget/moonbit_outline_folding.mbt similarity index 100% rename from editor/viewer/moonbit_outline_folding.mbt rename to editor/internal/viewer/code_editor_widget/moonbit_outline_folding.mbt diff --git a/editor/viewer/mouse_events.mbt b/editor/internal/viewer/code_editor_widget/mouse_events.mbt similarity index 88% rename from editor/viewer/mouse_events.mbt rename to editor/internal/viewer/code_editor_widget/mouse_events.mbt index a2fcdf734..5cb7352ff 100644 --- a/editor/viewer/mouse_events.mbt +++ b/editor/internal/viewer/code_editor_widget/mouse_events.mbt @@ -16,8 +16,8 @@ ///| /// Fires on editor mousedown with the hit-tested target — Monaco's /// `onMouseDown`. -pub fn Viewer::on_mouse_down( - self : Viewer, +pub fn CodeEditorWidget::on_mouse_down( + self : CodeEditorWidget, listener : (@editor_browser.EditorMouseEvent) -> Unit, ) -> @base_common.Disposable { self.did_mouse_down.event(listener) @@ -26,8 +26,8 @@ pub fn Viewer::on_mouse_down( ///| /// The view→model target conversion applied at the public boundary — /// `ViewUserInputEvents._convertViewToModelMouseEvent`. -fn Viewer::convert_mouse_target( - self : Viewer, +fn CodeEditorWidget::convert_mouse_target( + self : CodeEditorWidget, target : @editor_browser.MouseTarget, ) -> @editor_browser.MouseTarget { match self.get_view_model() { @@ -43,8 +43,8 @@ fn Viewer::convert_mouse_target( ///| /// `ViewUserInputEvents.emitMouseDown` → public event, then the hover /// controller's `_onEditorMouseDown` (its subscription in the source). -fn Viewer::emit_mouse_down( - self : Viewer, +fn CodeEditorWidget::emit_mouse_down( + self : CodeEditorWidget, event : @editor_browser.EditorDomMouseEvent, target : @editor_browser.MouseTarget, ) -> Unit { @@ -57,8 +57,8 @@ fn Viewer::emit_mouse_down( ///| /// `ViewUserInputEvents.emitMouseUp` → public event. -fn Viewer::emit_mouse_up( - self : Viewer, +fn CodeEditorWidget::emit_mouse_up( + self : CodeEditorWidget, event : @editor_browser.EditorDomMouseEvent, target : @editor_browser.MouseTarget, ) -> Unit { @@ -69,8 +69,8 @@ fn Viewer::emit_mouse_up( ///| /// `ViewUserInputEvents.emitMouseMove` → public event, then the hover /// controller's `_onEditorMouseMove`. -fn Viewer::emit_mouse_move( - self : Viewer, +fn CodeEditorWidget::emit_mouse_move( + self : CodeEditorWidget, event : @editor_browser.EditorDomMouseEvent, target : @editor_browser.MouseTarget, ) -> Unit { @@ -88,8 +88,8 @@ fn Viewer::emit_mouse_move( ///| /// `ViewUserInputEvents.emitContextMenu` → public event. The subscribing /// contribution owns native-menu suppression and command policy. -fn Viewer::emit_context_menu( - self : Viewer, +fn CodeEditorWidget::emit_context_menu( + self : CodeEditorWidget, event : @editor_browser.EditorDomMouseEvent, target : @editor_browser.MouseTarget, ) -> Unit { @@ -103,8 +103,8 @@ fn Viewer::emit_context_menu( /// `ViewUserInputEvents.emitMouseDrag`. The drag events have no public /// consumers (header); the hover keeps tracking the pointer during drags the /// way Monaco's controller ignores them (`_mouseDown` guard N-A here). -fn Viewer::emit_mouse_drag( - self : Viewer, +fn CodeEditorWidget::emit_mouse_drag( + self : CodeEditorWidget, _event : @editor_browser.EditorDomMouseEvent, _target : @editor_browser.MouseTarget, ) -> Unit { @@ -114,8 +114,8 @@ fn Viewer::emit_mouse_drag( ///| /// `ViewUserInputEvents.emitMouseLeave` → public event, then the hover /// controller's `_onEditorMouseLeave`. -fn Viewer::emit_mouse_leave( - self : Viewer, +fn CodeEditorWidget::emit_mouse_leave( + self : CodeEditorWidget, event : @editor_browser.EditorDomMouseEvent, ) -> Unit { self.did_mouse_leave.fire({ event, target: None }) diff --git a/editor/internal/viewer/code_editor_widget/native_render_lines.mbt b/editor/internal/viewer/code_editor_widget/native_render_lines.mbt new file mode 100644 index 000000000..69bf43bbe --- /dev/null +++ b/editor/internal/viewer/code_editor_widget/native_render_lines.mbt @@ -0,0 +1,332 @@ +// Native detached-line rendering for editor-owned ViewZones. This is the +// kernel counterpart of VS Code diffEditorViewZones/renderLines.ts: it uses a +// short-lived ViewModel projection and the same RenderLineInput pipeline as +// ordinary retained editor rows. Callers receive DOM and geometry only; no +// ViewModel, browser View, or configuration object crosses the boundary. + +///| +pub(all) struct CodeEditorRenderLinesDecoration { + range : @base_common.Range + class_name : String +} derive(Eq, Debug) + +///| +pub struct CodeEditorRenderedLines { + dom_node : @rdom.Element + margin_dom_node : @rdom.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 : CodeEditorRenderedLines, +) -> @rdom.Element { + self.dom_node +} + +///| +pub fn CodeEditorRenderedLines::margin_dom_node( + self : CodeEditorRenderedLines, +) -> @rdom.Element { + self.margin_dom_node +} + +///| +pub fn CodeEditorRenderedLines::height_in_px( + self : CodeEditorRenderedLines, +) -> Double { + self.height_in_px +} + +///| +pub fn CodeEditorRenderedLines::min_width_in_px( + self : CodeEditorRenderedLines, +) -> Double { + self.min_width_in_px +} + +///| +pub fn CodeEditorRenderedLines::view_line_count( + self : CodeEditorRenderedLines, +) -> Int { + self.view_line_count +} + +///| +pub fn CodeEditorRenderedLines::model_line_view_counts( + self : CodeEditorRenderedLines, +) -> Array[Int] { + self.model_line_view_counts.copy() +} + +///| +fn detached_line_decorations( + decorations : Array[CodeEditorRenderLinesDecoration], + converter : @view_model.CoordinatesConverter, + view_line_number : Int, + min_column : Int, + max_column : Int, +) -> Array[@view_layout.LineDecoration] { + let result : Array[@view_layout.LineDecoration] = [] + for decoration in decorations { + if decoration.range.is_empty() { + continue + } + let view_range = converter.model_range_to_view_range(decoration.range) + if view_line_number < view_range.start_line_number || + view_line_number > view_range.end_line_number { + continue + } + let start_column = (if view_line_number == view_range.start_line_number { + view_range.start_column + } else { + min_column + }) + .max(min_column) + .min(max_column) + let end_column = (if view_line_number == view_range.end_line_number { + view_range.end_column + } else { + max_column + }) + .max(min_column) + .min(max_column) + if start_column < end_column { + result.push( + LineDecoration(start_column, end_column, decoration.class_name), + ) + } + } + result +} + +///| +fn detached_render_line_input( + data : @view_layout.ViewLineRenderingData, + line_decorations : Array[@view_layout.LineDecoration], + configuration : EditorConfigurationSnapshot, +) -> @view_layout.RenderLineInput { + let font_info = configuration.font_info + @view_layout.RenderLineInput::from_view_line( + data, + line_decorations~, + use_monospace_optimizations=font_info.is_monospace, + can_use_halfwidth_rightwards_arrow=font_info.can_use_halfwidth_rightwards_arrow, + space_width=font_info.space_width, + middot_width=font_info.middot_width, + wsmiddot_width=font_info.wsmiddot_width, + stop_rendering_line_after=-1, + render_whitespace=configuration.options.render_whitespace, + render_control_characters=configuration.options.render_control_characters, + font_ligatures=font_info.font_feature_settings != + @config.editor_font_ligatures_off, + vertical_scrollbar_size=@core.editor_vertical_scrollbar_size, + ) +} + +///| +fn detached_line_class(extra : String) -> String { + if extra.is_empty() { + @viewer_common.render_line_class() + } else { + @viewer_common.render_line_class() + " " + extra + } +} + +///| +fn detached_margin_line_class(extra : String) -> String { + if extra.is_empty() { + "line-numbers" + } else { + "line-numbers " + extra + } +} + +///| +fn stabilize_render_lines_tokens( + model : @model.TextModel, + start_line_number : Int, + end_line_number_exclusive : Int, +) -> Unit { + let attached = model.on_before_attached() + attached.set_visible_lines( + [{ start_line_number, end_line_number: end_line_number_exclusive - 1 }], + true, + ) + model.on_before_detached(attached) +} + +///| +priv struct DetachedRenderedLineWindow { + content_html : String + margin_html : String + max_content_width : Double + model_line_numbers : Array[Int] +} + +///| +fn render_detached_view_line_window( + detached_view_model : @view_model.ViewModel, + configuration : EditorConfigurationSnapshot, + range_view_start : Int, + window_view_start : Int, + window_view_end_exclusive : Int, + decorations : Array[CodeEditorRenderLinesDecoration], + line_class_name : String, + margin_line_class_name : String, +) -> DetachedRenderedLineWindow { + let converter = detached_view_model.coordinates_converter() + let line_height = configuration.font_info.line_height + let content_html = StringBuilder() + let margin_html = StringBuilder() + let model_line_numbers : Array[Int] = [] + let line_class = @base_common.escape_html( + detached_line_class(line_class_name), + ) + let margin_line_class = @base_common.escape_html( + detached_margin_line_class(margin_line_class_name), + ) + let mut max_content_width = 0.0 + for view_line_number in window_view_start..\{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