fix(source): map original offsets to native CR text

Hosted source selections exposed a two-character drift after two newlines. Preserve canonical offsets and normalize only the native projection to one CR per break; retain exact viewport, fold and find assertions.

Refs #10

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-11 00:28:30 -05:00
1 parent ddb98ac286
commit 90b5406fe4
6 files changed
+34 -23

No files matched your search

+15 -1
View File
@@ -21,7 +21,7 @@ zero failures/skips, at artifacts/source-anchors-verified-installed-38113042618.
Fixtures remain synthetic/unsigned; all prior producers are terminal.
The current increment implements literal nested source folding and bidirectional
heading-anchor scroll mapping. Canonical snapshots remain immutable; native CRLF
heading-anchor scroll mapping. Canonical snapshots remain immutable; native CR
and folded offsets retain exact find selections. Browser geometry is bounded,
validated and current-page/query guarded; feedback holds preserve explicit find
viewports. Microsoft TextBoxView content coordinates do not receive a duplicate
@@ -375,3 +375,17 @@ quadratic full-remainder scan for adversarial long raw HTML lines. The source
snapshot remains immutable; no new capability/dependency is introduced. Update
PRODUCT/TODO with actual vim proof; source synchronization UI stays pending and
full scope remains active. No local GUI input, Azure attempt, merge/tag/release.
## Current native newline failure and correction
ddb98ac / CI 38114492408 native job 114396569276 finished FAILURE: 42/46,
zero skips. Existing paired source selections shift by two characters after two
line breaks; actual selections are pha** and *[x] followed by CR. WinUI's text
provider uses one CR per line break, not CRLF. The new model now normalizes display
to CR, subtracts only original CRLF extra characters and retains canonical source
positions. This also corrects heading geometry indices. The fold fixture compares
literal Setext text after newline normalization; all hide/reveal/find/reload and
actual viewport/physical-wheel assertions remain. Exact native log retained at
artifacts/source-sync-ui-38114492408.log. Build/core/format, CodeQL, scripts,
security and PR gates passed; installed producer is still observed before a push.
No local desktop input or weakening of coverage/selection/viewport checks.
+1 -1
View File
@@ -97,7 +97,7 @@ capability. These parser tests do not prove browser behavior. ADR status remains
## Literal source folding and scroll synchronization
A pure source projection normalizes native CRLF display offsets, retains heading
A pure source projection normalizes native CR display offsets, retains heading
lines and replaces folded bodies with plain ellipses. Nested choices remain
independent; search expands the projection without changing the original snapshot.
Native selections map original UTF-16 query ranges into the visible literal text.
+1 -1
View File
@@ -25,7 +25,7 @@ physical directory. It does not authorize native commands or remote resource acc
| File change notifications | Directory subscription scoped to selected path; 350 ms quiet-period debounce; replacement/deletion recovery; bounded transient retries; subscriptions released on window close |
| Theme JSON / CSS | Native editor and saved user overrides use the same bounded validation; corrupt theme settings are discarded before startup; restoration keeps separately named custom themes; embedded original palettes; bounded schema-version-one parser; validated hex colors and finite sizes; code-point escaped font names; less-than signs escaped before style embedding; no network capability |
| Browser viewport | Fixed origin-checked native query; validate two finite fractions; invariant numeric restore only; authored scripts/messages stay disabled |
| Source pane | Bounded immutable snapshot plus literal native fold projection; original UTF-16 find positions map to visible CRLF text; HTML/CSS/script text never enters a renderer or command interpreter; hidden pane removed from accessibility surface |
| Source pane | Bounded immutable snapshot plus literal native fold projection; original UTF-16 find positions map to visible native CR text; HTML/CSS/script text never enters a renderer or command interpreter; hidden pane removed from accessibility surface |
| Find | Bounded literal query; pure source positions and native WebView2 finder, with duplicate browser UI suppressed; no query enters executable code or activity logs. Serialized queries and page/navigation checks reject stale completions; existing numeric section operations reveal/restore content |
| Fixture input diagnostics | Explicit opt-in environment switch; observes handled native pointer/drag events without changing routing; logs owned control names, coordinates and button state only, never document text or keystrokes |
| Browser surface | Scripts, messages, host objects, permissions, downloads, frames and popups disabled; restrictive CSP |
@@ -12,10 +12,10 @@ namespace MarkdownViewr.Windows.Core.Documents;
/// <summary>Mirrors section folds in a literal native display while preserving the original snapshot and source identities.</summary>
public sealed class DocumentSourceText
{
private const string Ellipsis = "⋯\r\n";
private const string Ellipsis = "⋯\r";
private readonly string _source;
private readonly string _native;
private readonly int[] _singleBreaks;
private readonly int[] _crlfBreaks;
private readonly int[] _lineStarts;
private readonly DocumentSourceHeading[] _originalHeadings;
private readonly (int Index, int Start, int End)[] _sections;
@@ -40,8 +40,8 @@ public sealed class DocumentSourceText
throw new ArgumentException("Source positions must belong to the committed snapshot.", nameof(outline));
}
_source = source;
_native = source.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Replace("\n", "\r\n", StringComparison.Ordinal);
var singleBreaks = new List<int>();
_native = source.Replace("\r\n", "\r", StringComparison.Ordinal).Replace('\n', '\r');
var crlfBreaks = new List<int>();
var lineStarts = new List<int> { 0 };
for(int index = 0; index < source.Length; index++)
{
@@ -50,15 +50,12 @@ public sealed class DocumentSourceText
if(source[index] == '\r' && index + 1 < source.Length && source[index + 1] == '\n')
{
index++;
}
else
{
singleBreaks.Add(index);
crlfBreaks.Add(index);
}
lineStarts.Add(index + 1);
}
}
_singleBreaks = singleBreaks.ToArray();
_crlfBreaks = crlfBreaks.ToArray();
_lineStarts = lineStarts.ToArray();
_originalHeadings = outline.SourceHeadings.OrderBy(heading => heading.Start).ToArray();
_sections = new (int, int, int)[_originalHeadings.Length];
@@ -81,7 +78,7 @@ public sealed class DocumentSourceText
/// <summary>Visible literal text with Windows newlines and a plain ellipsis for omitted bodies, never HTML or rich text.</summary>
/// <summary>Visible literal text with native CR newlines and a plain ellipsis for omitted bodies, never HTML or rich text.</summary>
public string Text { get; private set; } = "";
@@ -163,11 +160,11 @@ public sealed class DocumentSourceText
/// <summary>Each original lone CR/LF expands by one native character; existing CRLF and Unicode text keep their lengths.</summary>
/// <summary>Each original CRLF loses its LF in WinUI text; lone CR/LF and Unicode retain their lengths.</summary>
private int ToNative(int position)
{
int index = Array.BinarySearch(_singleBreaks, position);
return position + (index >= 0 ? index : ~index);
int index = Array.BinarySearch(_crlfBreaks, position);
return position - (index >= 0 ? index : ~index);
}
@@ -20,15 +20,15 @@ public sealed class DocumentSourceTextTests
{
string original = string.Join(newline, "😀 Intro", "", "Parent", "======", "Parent body", "", "## Child", "Child body", "```md", "# Fake", "```", "", "> ## Peer", "> Peer body", "", "# Last", "Tail");
var source = new DocumentSourceText(original, new DocumentOutline(original));
Assert.Equal(original.ReplaceLineEndings("\r\n"), source.Text);
Assert.Equal(original.ReplaceLineEndings("\r"), source.Text);
Assert.Equal(4, source.Headings.Count);
Assert.True(source.SetCollapsed([1]));
Assert.Contains("Parent body", source.Text, StringComparison.Ordinal);
Assert.Contains("## Child\r\n⋯\r\n> ## Peer", source.Text, StringComparison.Ordinal);
Assert.Contains("## Child\r⋯\r> ## Peer", source.Text, StringComparison.Ordinal);
Assert.DoesNotContain("# Fake", source.Text, StringComparison.Ordinal);
Assert.Null(source.GetDisplayRange(original.IndexOf("Child body", StringComparison.Ordinal), 5));
Assert.True(source.SetCollapsed([0, 1]));
Assert.Contains("Parent\r\n======\r\n⋯\r\n# Last", source.Text, StringComparison.Ordinal);
Assert.Contains("Parent\r======\r⋯\r# Last", source.Text, StringComparison.Ordinal);
Assert.DoesNotContain("Child", source.Text, StringComparison.Ordinal);
Assert.Equal(new[] { 0, 3 }, source.Headings.Select(heading => heading.Index));
Assert.True(source.SetCollapsed([1]));
@@ -36,7 +36,7 @@ public sealed class DocumentSourceTextTests
Assert.DoesNotContain("Child body", source.Text, StringComparison.Ordinal);
Assert.False(source.SetCollapsed([1, 1]));
Assert.True(source.SetCollapsed([]));
Assert.Equal(original.ReplaceLineEndings("\r\n"), source.Text);
Assert.Equal(original.ReplaceLineEndings("\r"), source.Text);
}
@@ -73,7 +73,7 @@ public sealed class DocumentSourceTextTests
{
var source = new DocumentSourceText(original, new DocumentOutline(original));
source.SetCollapsed([-1, 0, 99]);
Assert.Equal(original.ReplaceLineEndings("\r\n"), source.Text);
Assert.Equal(original.ReplaceLineEndings("\r"), source.Text);
Assert.Throws<ArgumentNullException>(() => source.SetCollapsed(null!));
}
@@ -88,7 +88,7 @@ public sealed class DocumentSourceTextTests
source.SetCollapsed([0, 1]);
Assert.Contains("<h2>One</h2><h2>Two</h2>", source.Text, StringComparison.Ordinal);
Assert.DoesNotContain("Body", source.Text, StringComparison.Ordinal);
Assert.Contains("# Last\r\nTail", source.Text, StringComparison.Ordinal);
Assert.Contains("# Last\rTail", source.Text, StringComparison.Ordinal);
}
@@ -389,7 +389,7 @@ public class AppTests
WaitForSourceValue(session, source, value => !value.Contains("Grand body", StringComparison.Ordinal) && !value.Contains("# Fence pseudo", StringComparison.Ordinal) && value.Contains("### Source fold grandchild", StringComparison.Ordinal), "the folded native grandchild body and fence");
session.ClickElement("OutlineHeading:1");
session.ClickElement("ToggleSection");
WaitForSourceValue(session, source, value => !value.Contains("### Source fold grandchild", StringComparison.Ordinal) && value.Contains("Source fold peer\r\n----------------", StringComparison.Ordinal), "the folded parent and retained Setext peer");
WaitForSourceValue(session, source, value => !value.Contains("### Source fold grandchild", StringComparison.Ordinal) && value.ReplaceLineEndings("\n").Contains("Source fold peer\n----------------", StringComparison.Ordinal), "the folded parent and retained Setext peer");
session.InvokeMenuItem("Edit", "FindMenuItem");
session.EnterText("FindText", "Grand body");
WaitForFindCount(session, "1 of 1");