diff --git a/.editorconfig b/.editorconfig
index 939f463..abb18f4 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -30,4 +30,4 @@ indent_size = 2
indent_size = 2
[*.{xaml}]
-indent_size = 4
+indent_size = 2
diff --git a/.gitignore b/.gitignore
index de9d934..cf42f4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,11 @@
**/BundleArtifacts/
**/GeneratedArtifacts/
**/.vs/
-**/.vscode/
+# Ignore editor-local VSCode/VSCodium files, but commit the shared workspace
+# config (issue #22: 2-space XAML for VSCodium).
+**/.vscode/*
+!.vscode/settings.json
+!.vscode/extensions.json
*.user
*.suo
*.userosscache
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..7fb3b6e
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ // EditorConfig makes VSCodium honor .editorconfig (including 2-space XAML) for
+ // every file type, not just the [xml]/[xaml] overrides in settings.json.
+ "recommendations": [
+ "editorconfig.editorconfig"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..4af7e19
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,13 @@
+{
+ // XAML is treated as XML by VSCodium. Force 2-space indentation and stop the
+ // editor from guessing indent width from file contents (issue #22).
+ "editor.detectIndentation": false,
+ "[xml]": {
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true
+ },
+ "[xaml]": {
+ "editor.tabSize": 2,
+ "editor.insertSpaces": true
+ }
+}
diff --git a/App.xaml b/App.xaml
index 807e8bc..498a7ad 100644
--- a/App.xaml
+++ b/App.xaml
@@ -2,12 +2,12 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/AppShell.xaml b/AppShell.xaml
index 52fff8d..5da7606 100644
--- a/AppShell.xaml
+++ b/AppShell.xaml
@@ -4,5 +4,5 @@
xmlns:views="clr-namespace:PostXING.App.Views"
x:Class="PostXING.App.AppShell"
Shell.FlyoutBehavior="Disabled">
-
+
diff --git a/Platforms/Android/MainActivity.cs b/Platforms/Android/MainActivity.cs
index 64f7fa8..3564f92 100644
--- a/Platforms/Android/MainActivity.cs
+++ b/Platforms/Android/MainActivity.cs
@@ -10,10 +10,9 @@ namespace PostXING.App;
Theme = "@style/Maui.SplashTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
- // AdjustResize is kept as a hint, but target SDK 35+ edge-to-edge enforcement defeats it: the
- // window no longer shrinks for the soft keyboard. So EditorPage reads the IME inset natively and
- // pushes it into the editor JS, which sizes the editor above the keyboard (GH #39). Harmless
- // where AdjustResize still works.
+ // AdjustResize so the soft keyboard shrinks the WebView viewport instead of panning the
+ // window up; CodeMirror inside the editor then auto-scrolls the cursor into view. Without
+ // this the keyboard can sit on top of the last few lines of text.
WindowSoftInputMode = SoftInput.AdjustResize,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
diff --git a/Platforms/Windows/App.xaml b/Platforms/Windows/App.xaml
index a6d30e6..57e09c1 100644
--- a/Platforms/Windows/App.xaml
+++ b/Platforms/Windows/App.xaml
@@ -1,7 +1,7 @@
+ x:Class="PostXING.App.WinUI.App"
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:maui="using:Microsoft.Maui">
diff --git a/Resources/Raw/editor/index.html b/Resources/Raw/editor/index.html
index b85fc95..307fa6a 100644
--- a/Resources/Raw/editor/index.html
+++ b/Resources/Raw/editor/index.html
@@ -749,30 +749,20 @@
setCaretOffset(editor, offset);
}
- // Keep the editor sized to the actual visible area above the Android soft keyboard. The visual
- // viewport does NOT shrink for the soft keyboard on the Android WebView (measured: innerHeight,
- // visualViewport.height and this var all stay full-height whether the keyboard is up or down), so
- // the JS can't detect the keyboard on its own. Instead the Android host reads the IME inset
- // natively and pushes it here (in device px) via setKeyboardInsetPx; we subtract it from
- // innerHeight. On Windows hostInsetPx stays 0 and we use the visual viewport height unchanged
- // (there's no soft keyboard to exclude there).
- let hostInsetPx = 0; // soft-keyboard height in *device* px, pushed by the Android host (0 = hidden)
+ // Keep the editor sized to the visual viewport (the actual visible area, excluding the
+ // Android soft keyboard). Without this, AdjustResize doesn't always shrink the WebView's
+ // layout viewport, and content scrolls "off the bottom" behind the keyboard.
function syncEditorHeight() {
- let h;
- if (hostInsetPx > 0) {
- const dpr = window.devicePixelRatio || 1;
- h = Math.max(120, window.innerHeight - hostInsetPx / dpr);
- } else {
- h = window.visualViewport ? window.visualViewport.height : window.innerHeight;
- }
- document.documentElement.style.setProperty('--vv-h', h + 'px');
+ const vv = window.visualViewport;
+ if (!vv) return;
+ document.documentElement.style.setProperty('--vv-h', vv.height + 'px');
}
syncEditorHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
syncEditorHeight();
- // Once the new height is applied, bring the caret back into view; a frame's delay so the
- // height has taken effect and the rects are fresh.
+ // Once the keyboard finishes appearing, bring the caret back into view; a frame's
+ // delay so the new height has been applied and rects are fresh.
requestAnimationFrame(scrollCaretIntoView);
});
}
@@ -853,19 +843,6 @@
scheduleOutgoing();
});
- // Keep the caret visible as it moves — typing, newlines, arrows, taps — regardless of IME
- // composition state. The input handler above defers to compositionend while composing, which
- // would otherwise let the caret slide behind the keyboard mid-word; selectionchange fires on
- // every caret move, so it covers that gap. Throttled to one frame.
- let caretScrollScheduled = false;
- document.addEventListener('selectionchange', () => {
- if (caretScrollScheduled) return;
- const sel = window.getSelection();
- if (!sel || !sel.rangeCount || !editor.contains(sel.anchorNode)) return;
- caretScrollScheduled = true;
- requestAnimationFrame(() => { caretScrollScheduled = false; scrollCaretIntoView(); });
- });
-
// Paste as plain text (strip styles from the OS clipboard).
editor.addEventListener('paste', (e) => {
e.preventDefault();
@@ -932,14 +909,6 @@
focus() {
editor.focus();
},
- // Android host -> JS: the soft-keyboard height in device px (0 when hidden). The visual viewport
- // doesn't shrink for the keyboard on the Android WebView, so this is the only reliable signal;
- // we resize the editor to the visible area above the keyboard and pull the caret back into view.
- setKeyboardInsetPx(px) {
- hostInsetPx = (typeof px === 'number' && px > 0) ? px : 0;
- syncEditorHeight();
- requestAnimationFrame(scrollCaretIntoView);
- },
getText() {
return editor.innerText;
},
diff --git a/Resources/Styles/Colors.xaml b/Resources/Styles/Colors.xaml
index 461dee0..a7882a0 100644
--- a/Resources/Styles/Colors.xaml
+++ b/Resources/Styles/Colors.xaml
@@ -2,43 +2,43 @@
-
-
-
- #1E5BFF
- #4D7DFF
- #0A2BC2
- #06B6D4
- #0E7490
- #E08A2E
- #D97706
- #B25C00
-
-
- #7F170E
-
-
- #08081A
- #0F0F1F
- #1A1A2E
- #2D2D4A
-
-
- #FFFFFF
- #E5E7EB
- #9095A4
- #6B7080
-
-
- #10B981
- #F59E0B
- #EF4444
-
-
- #8C000000
-
-
- #2D2D4A
- #E5E7EB
+
+
+
+ #1E5BFF
+ #4D7DFF
+ #0A2BC2
+ #06B6D4
+ #0E7490
+ #E08A2E
+ #D97706
+ #B25C00
+
+
+ #7F170E
+
+
+ #08081A
+ #0F0F1F
+ #1A1A2E
+ #2D2D4A
+
+
+ #FFFFFF
+ #E5E7EB
+ #9095A4
+ #6B7080
+
+
+ #10B981
+ #F59E0B
+ #EF4444
+
+
+ #8C000000
+
+
+ #2D2D4A
+ #E5E7EB
diff --git a/Resources/Styles/Styles.xaml b/Resources/Styles/Styles.xaml
index 37c6b15..b9aa5f1 100644
--- a/Resources/Styles/Styles.xaml
+++ b/Resources/Styles/Styles.xaml
@@ -2,180 +2,180 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
diff --git a/Views/AboutPage.xaml b/Views/AboutPage.xaml
index decda4e..95816a8 100644
--- a/Views/AboutPage.xaml
+++ b/Views/AboutPage.xaml
@@ -5,107 +5,107 @@
BackgroundColor="{AppThemeBinding Light={StaticResource Paper100}, Dark={StaticResource Ink950}}"
Shell.NavBarIsVisible="False"
Title="About">
-
-
-
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
-
-
-
+
+
+
+
-
-
+
+
-
-
-
+
+
+
diff --git a/Views/EditorPage.xaml b/Views/EditorPage.xaml
index 73326c6..a92c2e5 100644
--- a/Views/EditorPage.xaml
+++ b/Views/EditorPage.xaml
@@ -9,193 +9,193 @@
Title="PostXING"
BackgroundColor="{AppThemeBinding Light={StaticResource Paper100}, Dark={StaticResource Ink950}}"
Shell.NavBarIsVisible="{OnPlatform Default=False, Android=True}">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Views/EditorPage.xaml.cs b/Views/EditorPage.xaml.cs
index aa53088..cb78b02 100644
--- a/Views/EditorPage.xaml.cs
+++ b/Views/EditorPage.xaml.cs
@@ -4,10 +4,6 @@
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Storage;
using PostXING.ViewModels;
-#if ANDROID
-using AndroidX.Core.View;
-using AView = Android.Views.View;
-#endif
namespace PostXING.App.Views;
@@ -83,10 +79,6 @@ public EditorPage(EditorViewModel vm, IPendingPostBox box, IPreviewBox previewBo
#if ANDROID
// Android's JS->host bridge can't live-sync edits, so pull the editor text on save.
vm.SyncBeforeSaveAsync = () => SyncEditorTextBeforeSaveAsync();
-
- // Edge-to-edge (target SDK 36) defeats AdjustResize, so the soft keyboard hides the lower
- // editor (GH #39). Hook the IME-inset -> editor-JS bridge once the WebView's handler exists.
- EditorWebView.HandlerChanged += (_, _) => HookAndroidImeInsets();
#endif
// HybridWebView serves Resources/Raw/editor (HybridRoot) and exposes a raw
@@ -107,7 +99,6 @@ protected override void OnAppearing()
_ = SeedEditorAsync();
#if ANDROID
StartDirtyPoll();
- HookAndroidImeInsets(); // backstop: the handler may already exist before HandlerChanged fired
#endif
}
@@ -329,59 +320,5 @@ private static string DecodeEvalString(string? raw)
try { return JsonSerializer.Deserialize(json) ?? raw; }
catch { return raw; }
}
-
- // Edge-to-edge enforcement (target SDK 35+) means WindowSoftInputMode=AdjustResize no longer
- // shrinks the window for the soft keyboard, so the lower editor lines hide behind the IME (GH
- // #39). The JS can't detect the keyboard either: on this Android WebView window.innerHeight and
- // visualViewport.height stay full-height whether the keyboard is up or down (measured via remote
- // debugging). So we read the IME inset natively here and push it (device px) into the editor JS,
- // which sizes the editor to the real visible area above the keyboard and keeps the caret in view.
- // Scoped to the WebView so MAUI's own system-bar inset handling elsewhere is left alone.
- private bool _imeInsetsHooked;
-
- private void HookAndroidImeInsets()
- {
- if (_imeInsetsHooked) return;
- if (EditorWebView.Handler?.PlatformView is not AView platformView) return;
- _imeInsetsHooked = true;
- ViewCompat.SetOnApplyWindowInsetsListener(platformView, new ImeInsetListener(PushKeyboardInsetToJs));
- ViewCompat.RequestApplyInsets(platformView);
- BridgeLog.Write("ime inset listener attached to WebView");
- }
-
- private void PushKeyboardInsetToJs(int imeBottomPx)
- {
- BridgeLog.Write($"ime inset bottom={imeBottomPx}");
- // Fire-and-forget like the other host->JS pushes: the awaited Task never completes on this
- // Android HybridWebView but the JS still runs. The if-guard makes a pre-load push harmless.
- MainThread.BeginInvokeOnMainThread(() =>
- {
- try { _ = EditorWebView.EvaluateJavaScriptAsync($"if(window.PostXING&&window.PostXING.setKeyboardInsetPx){{window.PostXING.setKeyboardInsetPx({imeBottomPx})}}"); }
- catch (Exception ex) { BridgeLog.Write($"PushKeyboardInset threw {ex.GetType().Name}: {ex.Message}"); }
- });
- }
-
- // Reads the IME (soft keyboard) inset on each window-insets pass and forwards its height in
- // device px when it changes. Returns the insets unconsumed — the WebView is a leaf, nothing
- // downstream depends on them; the diff-guard avoids redundant pushes on unrelated passes.
- private sealed class ImeInsetListener : Java.Lang.Object, IOnApplyWindowInsetsListener
- {
- private readonly Action _onInsetChanged;
- private int _lastBottom = -1;
-
- public ImeInsetListener(Action onInsetChanged) => _onInsetChanged = onInsetChanged;
-
- public WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets)
- {
- if (insets is null) return insets;
- var imeBottom = insets.GetInsets(WindowInsetsCompat.Type.Ime())?.Bottom ?? 0;
- if (imeBottom != _lastBottom)
- {
- _lastBottom = imeBottom;
- _onInsetChanged(imeBottom);
- }
- return insets;
- }
- }
#endif
}
diff --git a/Views/GhTerminalPage.xaml b/Views/GhTerminalPage.xaml
index 66c555a..8e5c89d 100644
--- a/Views/GhTerminalPage.xaml
+++ b/Views/GhTerminalPage.xaml
@@ -7,59 +7,59 @@
BackgroundColor="{StaticResource Ink950}"
Shell.NavBarIsVisible="False"
Title="gh">
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/Views/OpenPostPage.xaml b/Views/OpenPostPage.xaml
index 0405eb9..764acc3 100644
--- a/Views/OpenPostPage.xaml
+++ b/Views/OpenPostPage.xaml
@@ -10,111 +10,111 @@
BackgroundColor="{AppThemeBinding Light={StaticResource Paper100}, Dark={StaticResource Ink950}}"
Shell.NavBarIsVisible="{OnPlatform Default=False, Android=True}"
Title="Open post">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/PreviewPage.xaml b/Views/PreviewPage.xaml
index 73a9823..090fef4 100644
--- a/Views/PreviewPage.xaml
+++ b/Views/PreviewPage.xaml
@@ -7,19 +7,19 @@
BackgroundColor="{AppThemeBinding Light={StaticResource Paper100}, Dark={StaticResource Ink950}}"
Shell.NavBarIsVisible="False"
Title="Preview">
-
-
+
+
-
-
-
-
-
+
+
+
+
+
diff --git a/Views/SettingsPage.xaml b/Views/SettingsPage.xaml
index a4fbd2d..2bd454f 100644
--- a/Views/SettingsPage.xaml
+++ b/Views/SettingsPage.xaml
@@ -8,176 +8,176 @@
BackgroundColor="{AppThemeBinding Light={StaticResource Paper100}, Dark={StaticResource Ink950}}"
Shell.NavBarIsVisible="False"
Title="Settings">
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
+
-
-
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Views/SplashPage.xaml b/Views/SplashPage.xaml
index 2ed615c..e3ff093 100644
--- a/Views/SplashPage.xaml
+++ b/Views/SplashPage.xaml
@@ -5,80 +5,80 @@
Shell.NavBarIsVisible="False"
BackgroundColor="{StaticResource PhoenixBlue}">
-
-
-
-
-
+
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
-
+
+
diff --git a/src/PostXING.ViewModels/EditorViewModel.cs b/src/PostXING.ViewModels/EditorViewModel.cs
index 325eb2e..ec72da1 100644
--- a/src/PostXING.ViewModels/EditorViewModel.cs
+++ b/src/PostXING.ViewModels/EditorViewModel.cs
@@ -177,26 +177,9 @@ public void LoadPost(PostHandle handle, string contents)
partial void OnRawMarkdownChanged(string value)
{
if (!_seeding) IsDirty = true;
-
- // This runs on every keystroke, and while the user is mid-edit the YAML front matter is
- // routinely, transiently invalid (an unterminated quote, a half-typed list) -- the parser
- // throws out of Parse. Never let that escape the setter: on desktop it would crash the
- // async-void WebView "change" handler; on Android it re-threw out of the ~750ms dirty-poll
- // every tick. Keep the last-good FrontMatter and still refresh the word count off the raw
- // buffer so metadata stays live until the YAML parses again.
- string body;
- try
- {
- var parsed = _parser.Parse(value);
- FrontMatter = parsed.FrontMatter;
- body = parsed.Body;
- }
- catch
- {
- body = value;
- }
-
- var words = body.Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length;
+ var parsed = _parser.Parse(value);
+ FrontMatter = parsed.FrontMatter;
+ var words = parsed.Body.Split([' ', '\t', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length;
WordCount = words;
ReadingTimeMinutes = Math.Max(1, (int)Math.Ceiling(words / 200.0));
}
diff --git a/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs b/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs
index d7883a7..126ee82 100644
--- a/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs
+++ b/tests/PostXING.ViewModels.Tests/EditorViewModelTests.cs
@@ -1,5 +1,4 @@
using NSubstitute;
-using NSubstitute.ExceptionExtensions;
using PostXING.Core.Domain;
using PostXING.GitHub;
using PostXING.Markdown;
@@ -298,108 +297,4 @@ public void Merge_is_disabled_when_no_pr_is_pending()
var vm = GitHubVm(out _);
vm.MergeCommand.CanExecute(null).ShouldBeFalse();
}
-
- // ---- Malformed front matter while typing must not crash the editor ----
- //
- // OnRawMarkdownChanged runs on every keystroke. While the user is mid-edit the YAML front
- // matter is routinely, transiently invalid (an unterminated quote, a half-typed list) and the
- // parser throws out of Parse. That exception used to propagate out of the RawMarkdown setter:
- // on desktop it escaped the async-void WebView "change" handler (crash); on Android it re-threw
- // out of the ~750ms dirty-poll every tick (PXBRIDGE log spam + frozen WordCount/FrontMatter).
- // The setter must swallow the failure, keep the last-good FrontMatter, and keep the count live.
-
- private static EditorViewModel NewVm(IFrontMatterParser parser)
- {
- var gateway = Substitute.For();
- gateway.CheckAuthAsync(Arg.Any()).Returns(new GhAuthStatus(false, null, "stubbed"));
- var settings = Substitute.For();
- settings.Current.Returns(AppSettings.Default);
- var local = Substitute.For();
- return new EditorViewModel(parser, gateway, settings, local, TimeProvider.System, StubGit(), new GitHubPublishService(gateway));
- }
-
- [Fact]
- public void Typing_malformed_frontmatter_does_not_throw_out_of_the_RawMarkdown_setter()
- {
- const string malformed =
- "---\n" +
- "title: \"half-typed quote\n" + // opens a double-quoted scalar that never closes
- "---\n\n" +
- "body text\n";
-
- // Guard: the real parser genuinely throws on this input, so the assertion below isn't vacuous.
- Should.Throw(() => new YamlFrontMatterParser().Parse(malformed));
-
- var vm = NewVm(new YamlFrontMatterParser());
- vm.CancelTitlePromptCommand.Execute(null);
- vm.IsDirty = false;
-
- Should.NotThrow(() => vm.RawMarkdown = malformed);
-
- vm.IsDirty.ShouldBeTrue("a keystroke still marks the buffer dirty even when the YAML can't parse");
- vm.RawMarkdown.ShouldBe(malformed);
- }
-
- [Fact]
- public void Malformed_frontmatter_keeps_the_word_count_live_over_the_raw_buffer()
- {
- // 7 whitespace-separated tokens across the whole buffer (---, oops:, "unterminated, ---,
- // alpha, beta, gamma). With the front matter unparseable we can't split it from the body,
- // so the whole raw buffer is counted -- the point is the count stays live, not frozen at 0.
- const string malformed =
- "---\n" +
- "oops: \"unterminated\n" +
- "---\n" +
- "alpha beta gamma\n";
-
- var vm = NewVm(new YamlFrontMatterParser());
- vm.CancelTitlePromptCommand.Execute(null);
-
- vm.RawMarkdown = malformed;
-
- vm.WordCount.ShouldBe(7);
- vm.ReadingTimeMinutes.ShouldBe(1);
- }
-
- [Fact]
- public void Valid_frontmatter_after_a_malformed_edit_recovers_the_metadata()
- {
- var vm = NewVm(new YamlFrontMatterParser());
- vm.CancelTitlePromptCommand.Execute(null);
-
- // 1) a clean document parses normally
- vm.RawMarkdown = "---\ntitle: Alpha\n---\n\nfirst body\n";
- vm.FrontMatter.Title.ShouldBe("Alpha");
-
- // 2) the YAML goes transiently invalid: no throw, and the last-good title is retained
- vm.RawMarkdown = "---\ntitle: \"Alpha and then some\n---\n\nstill editing\n";
- vm.FrontMatter.Title.ShouldBe("Alpha", "a transient parse failure keeps the last-good front matter");
-
- // 3) the YAML is valid again: metadata recovers from the new front matter
- vm.RawMarkdown = "---\ntitle: Bravo\n---\n\nrecovered body here\n";
- vm.FrontMatter.Title.ShouldBe("Bravo");
- vm.WordCount.ShouldBe(3, "the body word count tracks the recovered document");
- }
-
- [Fact]
- public void Any_parser_exception_is_swallowed_and_the_last_good_frontmatter_is_kept()
- {
- // The VM defends against *any* IFrontMatterParser throwing, not just YamlDotNet -- the
- // interface doesn't promise Parse won't throw, so the setter must never let it escape.
- var parser = Substitute.For();
- parser.Parse(Arg.Any()).Returns(new ParsedDocument(FrontMatter.Default.WithTitle("Good"), "good body"));
- var vm = NewVm(parser);
- vm.CancelTitlePromptCommand.Execute(null);
-
- vm.RawMarkdown = "anything";
- vm.FrontMatter.Title.ShouldBe("Good");
-
- parser.Parse("now totally broken text").Throws(new InvalidOperationException("kaboom"));
-
- Should.NotThrow(() => vm.RawMarkdown = "now totally broken text");
-
- vm.FrontMatter.Title.ShouldBe("Good", "the last-good front matter survives a parse failure");
- vm.IsDirty.ShouldBeTrue();
- vm.WordCount.ShouldBe(4, "word count falls back to the raw buffer (now, totally, broken, text)");
- }
}