feat(editor): configure literal external document launch

Persist a validated editor executable, expose native configuration and launch commands, and preserve the viewer on missing files or launch errors. Add bounded argument tests and hosted actual-process fixtures; editor-list and folder parity remain tracked in issue #11.

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-10 22:55:12 -05:00
1 parent 2292603569
commit 574b57432a
13 files changed
+360 -3

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
- **View → Document theme:** choose one of Darin's 20 built-in palettes. The saved document palette is independent of the Windows shell theme; changing it retains the reading position.
- **View → Edit document theme:** edit colors, code font/size and custom CSS. Keep its name to override a built-in, or enter a new name to create a custom theme. Invalid input keeps the editor open. **Restore built-in themes** removes built-in edits after confirmation and keeps separately named custom themes.
- **View → Table of contents:** show the heading sidebar, choose levels 1–6, and drag its divider to resize. Select a heading to jump there; collapse a selected section or all sections, then expand when ready. Matching section state survives reloads and document theme changes.
- **File → Configure editor / Open in editor:** save the full path of an installed `.exe`, then open the current document with one literal file argument. A missing editor or document reports a status message; the viewer remains open. Reference editor-list/folder-opening parity and hosted acceptance remain pending.
- **Edit → Find / Ctrl+F:** search the rendered document and literal source, with one occurrence count and wrapping **Next/Previous** controls. Enter moves forward, Shift+Enter moves back, and Escape closes find. Search temporarily reveals collapsed sections; closing it restores prior choices and retains explicit section commands made during search.
- **View → Markdown source:** show the literal, read-only snapshot beside the reader, including frontmatter and fenced code. Visibility and preferred divider width persist; saves refresh both surfaces. Heading/scroll/collapse synchronization and vim keys remain in development; paired-find desktop acceptance is pending.
- **View → Frontmatter:** hide leading YAML by default, show an escaped metadata table, or render the block as Markdown. The mode is saved and applied to reloads.
+18
View File
@@ -205,3 +205,21 @@ and artifacts/window-find-installed-38109196556.log.
Separate local editor work is tracked by issue #11: literal ProcessStartInfo
ArgumentList, persisted executable selection and native controls. Its 195 core
tests/100% lines pass; that work is not included in this keyboard correction.
## Configured editor increment prepared locally
Issue #11 tracks editor launch and the broader reference list/folder parity audit.
The local increment adds a sanitized persisted executable path, native configure
Save/Cancel and open commands, missing-editor/document status, and one literal
ProcessStartInfo.ArgumentList value with UseShellExecute=false. No arguments or
source content determine executable selection; operational logs omit paths/content.
195 core tests pass with 100% measured lines; final native build has zero warnings
and formatting passes. Three new hosted tests use the existing test-only UpdateProbe
in an environment-selected capture mode; it never reads document contents. They
verify actual process arguments/working directory, saved configuration, invalid
selection/cancel and an editor removed after selection. No local GUI input occurred.
This editor work remains separate from pushed keyboard head 2292603 until its
live CI 38109719180 ends. CodeQL 38109719207 remains under observation;
scripts 38109718804, security 38109719182 and PR gates 38109719194 passed.
Keyboard/fragment and editor hosted acceptance remain unverified; full scope active.
+3 -1
View File
@@ -27,7 +27,9 @@
- [x] Implement and core-test hidden/metadata/Markdown frontmatter modes, with persisted native controls and attribution.
- [x] Verify actual code-font size 17 editing in hosted UI runs.
- [x] Verify actual hosted frontmatter mode switching on 4bf0b81 (including both installed channels).
- [ ] Add safe configured editor launch and printing options.
- [x] Implement bounded persisted editor selection and deliberate native launch using one literal process argument (issue #11).
- [ ] Verify real hosted editor fixture launch, validation/cancel and missing-executable acceptance; audit editor list/folder parity.
- [ ] Add native printing/theme/layout options and screen-state restoration.
- [ ] Add multiple document windows, recent files and Windows activation/file associations.
- [x] Verify the all-rule two-pass PSScriptAnalyzer mitigation on scripts 38104988748 and 38105483489; closes #5 only on reviewed merge.
- [ ] Verify hosted checks on each actual implementation head.
+12
View File
@@ -79,3 +79,15 @@ through; closing the window removes the callback. No global keyboard registratio
other-process input capture, arbitrary command dispatch, browser bridge or text
logging is introduced. Actual hosted browser-focus acceptance is still required.
See Microsoft's [thread-hook contract](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowshookexw).
## Configured editor boundary
Only deliberate native configuration selects an existing, bounded, fully qualified
`.exe`. AppKit sanitizes the saved choice. Opening the editor requires a current
existing document and passes its full path as exactly one `ArgumentList` value with
`UseShellExecute=false`; Markdown cannot choose an executable or supply switches.
The chosen application runs with the user's permissions and receives the document.
Missing files and native launch errors leave the viewer open; logs omit paths and
contents. Core tests cover metacharacters/Unicode, relative/script paths, corruption
and bounds. Actual fixture-process/UI acceptance is pending; reference editor-list
and folder-opening parity still require review.
@@ -56,10 +56,16 @@ public sealed class AppSettings : ShellSettings
/// <summary>The user-selected external editor; an empty value requires deliberate configuration.</summary>
public string ExternalEditor { get; set; } = "";
/// <inheritdoc/>
public override void Sanitize()
{
base.Sanitize();
ExternalEditor = Editors.EditorLaunch.IsExecutablePath(ExternalEditor) ? ExternalEditor : "";
SourceWidth = double.IsFinite(SourceWidth) ? Math.Clamp(SourceWidth, 160, 600) : 320;
OutlineDepth = Math.Clamp(OutlineDepth, 1, 6);
OutlineWidth = double.IsFinite(OutlineWidth) ? Math.Clamp(OutlineWidth, 140, 500) : 220;
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
namespace MarkdownViewr.Windows.Core.Editors;
/// <summary>Builds an explicit editor process without interpreting a document path as a shell command.</summary>
public static class EditorLaunch
{
/// <summary>Only a bounded, fully qualified Windows executable can be persisted as the chosen editor.</summary>
public static bool IsExecutablePath(string? path)
=> path is { Length: > 0 and <= 32767 } && !path.Any(char.IsControl) && Path.IsPathFullyQualified(path) && string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase);
/// <summary>Supplies exactly one literal file argument; filesystem availability is checked by the Windows adapter.</summary>
public static ProcessStartInfo Create(string executable, string document)
{
if(!IsExecutablePath(executable))
{
throw new ArgumentException("Choose an editor executable using its full path.", nameof(executable));
}
if(string.IsNullOrEmpty(document) || document.Length > 32767 || document.Any(char.IsControl) || !Path.IsPathFullyQualified(document))
{
throw new ArgumentException("The document must have a full file path.", nameof(document));
}
var start = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(document),
};
start.ArgumentList.Add(document);
return start;
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace MarkdownViewr.Windows;
/// <summary>Routes deliberate native editor commands through a literal process argument boundary.</summary>
public sealed partial class MainWindow
{
/// <summary>A configured editor becomes available only after a document has been committed.</summary>
private void RefreshEditorCommands()
=> OpenEditorMenuItem.IsEnabled = _documentPath is not null && Core.Editors.EditorLaunch.IsExecutablePath(_settings.ExternalEditor);
/// <summary>Validates an installed executable before persisting the user's deliberate choice.</summary>
private async void ConfigureEditor_Click(object sender, RoutedEventArgs e)
{
var path = new TextBox { Name = "EditorPath", Header = "Editor executable path", Text = _settings.ExternalEditor, MaxLength = 32767, MinWidth = 320 };
var error = new TextBlock { Name = "EditorError", TextWrapping = TextWrapping.Wrap };
var panel = new StackPanel { Spacing = 12 };
panel.Children.Add(new TextBlock { Text = "Enter the full path of an installed Windows editor. It receives the current document as one file argument.", TextWrapping = TextWrapping.Wrap });
panel.Children.Add(path);
panel.Children.Add(error);
var dialog = new ContentDialog
{
Title = "Configure editor",
Content = panel,
PrimaryButtonText = "Save",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Primary,
XamlRoot = Content.XamlRoot,
};
dialog.PrimaryButtonClick += (_, args) =>
{
if(!Core.Editors.EditorLaunch.IsExecutablePath(path.Text) || !File.Exists(path.Text))
{
args.Cancel = true;
error.Text = "Enter the full path of an existing .exe file.";
}
};
if(await dialog.ShowAsync() != ContentDialogResult.Primary || _closed)
{
return;
}
_settings.ExternalEditor = path.Text;
Save();
RefreshEditorCommands();
StatusText.Text = "Editor configured.";
}
/// <summary>Launches only the selected executable and never interpolates Markdown or a shell command.</summary>
private void OpenEditor_Click(object sender, RoutedEventArgs e)
{
if(_closed || _documentPath is null)
{
return;
}
if(!File.Exists(_settings.ExternalEditor))
{
StatusText.Text = "The configured editor is missing. Use File → Configure editor to choose an installed executable.";
return;
}
if(!File.Exists(_documentPath))
{
StatusText.Text = "The document is missing. Restore it before opening the editor.";
return;
}
try
{
using var process = Process.Start(Core.Editors.EditorLaunch.Create(_settings.ExternalEditor, _documentPath));
StatusText.Text = process is not null ? "Opened document in the configured editor." : "The configured editor did not start.";
App.Log.Info("Requested a document launch in the configured editor.");
}
catch(Exception exception) when(exception is System.ComponentModel.Win32Exception or IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException)
{
StatusText.Text = "Could not start the configured editor. Use File → Configure editor to check its executable.";
App.Log.Warning("The configured editor could not be started.");
}
}
}
@@ -29,6 +29,9 @@
</MenuFlyoutItem>
<MenuFlyoutItem x:Name="ReloadMenuItem" Text="Reload" Click="Reload_Click" IsEnabled="False" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="OpenEditorMenuItem" Text="Open in editor" Click="OpenEditor_Click" IsEnabled="False" />
<MenuFlyoutItem x:Name="ConfigureEditorMenuItem" Text="Configure editor..." Click="ConfigureEditor_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="ExitMenuItem" Text="Exit" Click="Exit_Click" />
</MenuBarItem>
<MenuBarItem Title="Edit">
@@ -42,6 +42,7 @@ public sealed partial class MainWindow : Window, IDisposable
InitializeOutline();
InitializeSource();
InitializeFind();
RefreshEditorCommands();
InitializeInputDiagnostics();
(_settings.FrontmatterKind switch { Core.Documents.FrontmatterMode.Metadata => FrontmatterMetadata, Core.Documents.FrontmatterMode.Markdown => FrontmatterMarkdown, _ => FrontmatterHide }).IsChecked = true;
Title = AppIdentity.Info.DisplayName;
@@ -290,6 +291,7 @@ public sealed partial class MainWindow : Window, IDisposable
_outline = outline;
RefreshOutline();
_documentPath = Path.GetFullPath(path);
RefreshEditorCommands();
Title = Path.GetFileName(path) + " — " + AppIdentity.Info.DisplayName;
StatusText.Text = (reload ? "Reloaded " : "Opened ") + Path.GetFileName(path);
App.Log.Info(reload ? "Reloaded a Markdown document." : "Opened a Markdown document.");
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using MarkdownViewr.Windows.Core.Editors;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Proves chosen executable boundaries and literal document arguments without launching an external application.</summary>
public sealed class EditorLaunchTests
{
/// <summary>Whitespace, command punctuation and Unicode remain one process argument rather than shell syntax.</summary>
[Theory]
[InlineData(@"C:\Documents\my notes.md")]
[InlineData(@"C:\Documents\日本語 café.md")]
[InlineData(@"C:\Documents\x & calc; $HOME `whoami`.md")]
[InlineData(@"C:\Documents\--reuse-window.md")]
public void Create_LiteralDocument_PreservesSingleArgument(string document)
{
System.Diagnostics.ProcessStartInfo start = EditorLaunch.Create(@"C:\Program Files\Editor\Editor.EXE", document);
Assert.Equal(@"C:\Program Files\Editor\Editor.EXE", start.FileName);
Assert.False(start.UseShellExecute);
Assert.Equal(@"C:\Documents", start.WorkingDirectory);
Assert.Equal(document, Assert.Single(start.ArgumentList));
Assert.Empty(start.Arguments);
}
/// <summary>Relative commands, scripts, malformed saved settings and oversized paths cannot select the editor.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("editor.exe")]
[InlineData(@"C:editor.exe")]
[InlineData(@"C:\editor.cmd")]
[InlineData("C:\\editor\n.exe")]
public void Create_InvalidExecutable_RejectsChoice(string? executable)
{
Assert.False(EditorLaunch.IsExecutablePath(executable));
Assert.Throws<ArgumentException>(() => EditorLaunch.Create(executable!, @"C:\Documents\test.md"));
var settings = new AppSettings { ExternalEditor = executable! };
settings.Sanitize();
Assert.Empty(settings.ExternalEditor);
}
/// <summary>Saved paths are bounded independently of filesystem existence.</summary>
[Fact]
public void Sanitize_ExecutableLength_RepairsOversizedChoiceAndRetainsValidChoice()
{
string oversized = "C:\\" + new string('a', 32767) + ".exe";
Assert.False(EditorLaunch.IsExecutablePath(oversized));
Assert.Throws<ArgumentException>(() => EditorLaunch.Create(oversized, @"C:\Documents\test.md"));
var settings = new AppSettings { ExternalEditor = oversized };
settings.Sanitize();
Assert.Empty(settings.ExternalEditor);
settings.ExternalEditor = @"C:\Editor\editor.exe";
settings.Sanitize();
Assert.Equal(@"C:\Editor\editor.exe", settings.ExternalEditor);
}
/// <summary>An incomplete or corrupt document path cannot be passed to an editor.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("relative.md")]
[InlineData("C:\\Documents\\bad\nname.md")]
public void Create_InvalidDocument_RejectsArgument(string? document)
=> Assert.Throws<ArgumentException>(() => EditorLaunch.Create(@"C:\Editor\editor.exe", document!));
/// <summary>Oversized document paths are rejected before process construction.</summary>
[Fact]
public void Create_OversizedDocument_RejectsArgument()
=> Assert.Throws<ArgumentException>(() => EditorLaunch.Create(@"C:\Editor\editor.exe", "C:\\" + new string('x', 32767)));
}
@@ -32,7 +32,7 @@ public sealed class AppSession : IDisposable
/// <summary>Launches the app against a fresh temporary data folder and waits for its main window.</summary>
public AppSession(bool showSplash = false, string? document = null)
public AppSession(bool showSplash = false, string? document = null, bool editorProbe = false, string documentFileName = "document.md")
{
ExePath = ResolveExePath();
DataDirectory = Path.Combine(Path.GetTempPath(), AppIdentity.Info.Id + "-uitests", Guid.NewGuid().ToString("N"));
@@ -44,9 +44,13 @@ public sealed class AppSession : IDisposable
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
startInfo.Environment[AppIdentity.Info.DataDirectoryVariable] = DataDirectory;
startInfo.Environment["MARKDOWNVIEWR_INPUT_TRACE"] = "1";
if(editorProbe)
{
startInfo.Environment["MARKDOWNVIEWR_EDITOR_PROBE_RESULT"] = Path.Combine(DataDirectory, "editor-result.json");
}
if(document is not null)
{
string path = Path.Combine(DataDirectory, "document.md");
string path = Path.Combine(DataDirectory, documentFileName);
File.WriteAllText(path, document);
startInfo.ArgumentList.Add(path);
}
@@ -29,6 +29,85 @@ public class AppTests
/// <summary>The native command launches the chosen real fixture process with one literal document path.</summary>
[Fact]
public void Editor_ConfiguredExecutable_LaunchesLiteralDocument()
{
using var session = new AppSession(document: "# Editor fixture", editorProbe: true, documentFileName: "notes & café $HOME.md");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Editor fixture")), "the editor fixture heading", TimeSpan.FromSeconds(30));
string editor = ResolveEditorProbe();
session.InvokeMenuItem("File", "ConfigureEditorMenuItem");
session.EnterText("EditorPath", editor);
session.ClickButton("Save");
session.WaitForElementGone("EditorPath");
using(var settings = JsonDocument.Parse(session.ReadSettings()))
{
Assert.Equal(editor, settings.RootElement.GetProperty("ExternalEditor").GetString());
}
session.InvokeMenuItem("File", "OpenEditorMenuItem");
string resultPath = Path.Combine(session.DataDirectory, "editor-result.json");
session.WaitFor(() => File.Exists(resultPath) ? session.MainWindow : null, "the actual editor fixture's result file");
using var result = JsonDocument.Parse(File.ReadAllText(resultPath));
Assert.Equal(Path.Combine(session.DataDirectory, "notes & café $HOME.md"), Assert.Single(result.RootElement.GetProperty("Arguments").EnumerateArray()).GetString());
Assert.Equal(session.DataDirectory, result.RootElement.GetProperty("WorkingDirectory").GetString());
Assert.NotNull(session.FindInApp(cf => cf.ByName("Editor fixture")));
Assert.DoesNotContain("notes & café", session.ReadLogs(), StringComparison.Ordinal);
}
/// <summary>A missing choice keeps the dialog open; cancel does not change saved settings.</summary>
[Fact]
public void Editor_InvalidExecutableAndCancel_DoesNotPersist()
{
using var session = new AppSession();
session.InvokeMenuItem("File", "ConfigureEditorMenuItem");
session.EnterText("EditorPath", Path.Combine(session.DataDirectory, "missing.exe"));
session.ClickButton("Save");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Enter the full path of an existing .exe file.")), "the editor validation error");
Assert.NotNull(session.FindInApp(cf => cf.ByAutomationId("EditorPath")));
session.ClickButton("Cancel");
session.WaitForElementGone("EditorPath");
Assert.DoesNotContain("missing.exe", session.ReadSettings(), StringComparison.Ordinal);
}
/// <summary>An editor uninstalled after configuration reports the problem without launching anything.</summary>
[Fact]
public void Editor_MissingConfiguredExecutable_PreservesDocument()
{
using var session = new AppSession(document: "# Missing editor fixture");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Missing editor fixture")), "the fixture heading", TimeSpan.FromSeconds(30));
string editor = Path.Combine(session.DataDirectory, "removed-editor.exe");
File.WriteAllText(editor, "Never executed; presence-only configuration fixture.");
session.InvokeMenuItem("File", "ConfigureEditorMenuItem");
session.EnterText("EditorPath", editor);
session.ClickButton("Save");
session.WaitForElementGone("EditorPath");
File.Delete(editor);
session.InvokeMenuItem("File", "OpenEditorMenuItem");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("The configured editor is missing. Use File → Configure editor to choose an installed executable.")), "the missing editor status");
Assert.NotNull(session.FindInApp(cf => cf.ByName("Missing editor fixture")));
}
/// <summary>Uses the solution's existing test-only executable, never a real user's editor.</summary>
private static string ResolveEditorProbe()
{
for(DirectoryInfo? current = new(AppContext.BaseDirectory); current is not null; current = current.Parent)
{
if(File.Exists(Path.Combine(current.FullName, "MarkdownViewr.Windows.slnx")))
{
string path = Path.Combine(current.FullName, "tests", "MarkdownViewr.Windows.UpdateProbe", "bin", "Debug", "net10.0-windows", "MarkdownViewr.Windows.UpdateProbe.exe");
return File.Exists(path) ? path : throw new FileNotFoundException("Build the solution's test-only editor probe first.", path);
}
}
throw new FileNotFoundException("Could not locate the solution's editor probe.");
}
/// <summary>Splash: shows, then dismisses itself.</summary>
[Fact]
public void Splash_ShowsThenDismissesItself()
@@ -18,6 +18,10 @@ internal static class Program
/// <summary>Records the installed channel, update offer and requests; optionally downloads and applies it.</summary>
private static async Task<int> Main(string[] args)
{
if(Environment.GetEnvironmentVariable("MARKDOWNVIEWR_EDITOR_PROBE_RESULT") is { Length: > 0 } editorResult)
{
return WriteEditorResult(editorResult, args);
}
VelopackStartup.Run();
if(args.Length != 3 || args[2] is not ("check" or "apply"))
{
@@ -73,4 +77,18 @@ internal static class Program
}
return 0;
}
/// <summary>Records only the owned fixture's actual process arguments; it never opens or reads the document.</summary>
private static int WriteEditorResult(string resultPath, string[] args)
{
if(args.Length != 1)
{
throw new ArgumentException("The editor fixture expects one literal document argument.", nameof(args));
}
File.WriteAllText(resultPath + ".tmp", JsonSerializer.Serialize(new { Arguments = args, WorkingDirectory = Environment.CurrentDirectory }));
File.Move(resultPath + ".tmp", resultPath, overwrite: true);
return 0;
}
}