From 43f5eaf11f01bc75a6aa7efb29791eb3b363b756 Mon Sep 17 00:00:00 2001 From: DevMando Date: Sun, 30 Aug 2026 20:34:21 -0700 Subject: [PATCH] Add docked project file previews --- CHANGELOG.md | 11 + MandoCode | 2 +- .../Controls/ChatTabView.Explorer.cs | 377 +++++++++++++++++- .../Controls/ChatTabView.xaml | 69 +++- .../Controls/ChatTabView.xaml.cs | 15 +- .../Services/AgentSession.cs | 10 + .../Services/DesktopPreviewTools.cs | 93 +++++ 7 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 src/MandoCode.Desktop/Services/DesktopPreviewTools.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 19df687..0698250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ for every approved plan. Desktop's version follows the engine generation, so it 0.15.0. ### Added +- **Docked file previews from the Explorer.** Selecting a file opens a resizable, read-only + preview between the chat and file tree. Code, text, configuration, documentation, and common + image formats open in Desktop; unsupported or large files offer the existing external-open + action. An open preview reloads once after an agent turn so it shows the final saved file state. + Text previews can enter an explicit edit mode and save with the button or `Ctrl+S`; unsaved + edits are protected from agent refreshes and saving warns before replacing a newer disk version. + HTML, SVG, and local page assets render in a project-local browser mode with an in-pane reload + control; browser navigation cannot replace the preview with an external site. The agent can open + a finished HTML, HTM, or SVG page in that pane and refresh an open page through its normal tool + calls, while the pane also refreshes once automatically when a turn changes the open file. + Opening a file never adds it to the agent's context—use the explicit `@` button to attach it. - **Separate tags for Skills and MCP servers.** Use the new gear-icon **Tags** button on each row to assign tags from a checkbox menu; assigned tags appear as colored chips beside that button. The `+` button in Filters opens a tag-management dialog where new tags receive a chosen color. diff --git a/MandoCode b/MandoCode index 7aede43..29c745c 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 7aede431fb53ba46db630ae4384fc8189b4ed5b5 +Subproject commit 29c745c1147216153200f0aa512e55a61c46ed0c diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs index edfa552..a3826f6 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Explorer.cs @@ -9,6 +9,7 @@ using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Animation; +using Microsoft.UI.Xaml.Media.Imaging; using Windows.ApplicationModel.DataTransfer; using Windows.System; @@ -288,6 +289,34 @@ await Task.Run(async () => private bool _explorerOpen; private string? _explorerRoot; // root the tree was last built for + private bool _previewOpen; + private string? _previewPath; + private string _previewTitle = ""; + private bool _previewEditing; + private bool _previewDirty; + private bool _settingPreviewText; + private DateTime _previewLoadedWriteTimeUtc; + private long _previewLoadedLength; + private bool _browserPreview; + private bool _previewBrowserReady; + private const string PreviewBrowserHost = "preview.mandocode.local"; + + private static readonly HashSet PreviewableTextExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".cs", ".csproj", ".sln", ".props", ".targets", ".xaml", ".xml", ".json", ".jsonc", + ".yaml", ".yml", ".toml", ".ini", ".config", ".md", ".markdown", ".txt", ".log", + ".html", ".htm", ".css", ".scss", ".js", ".ts", ".tsx", ".jsx", ".py", ".ps1", + ".sh", ".sql", ".csv", ".svg" + }; + private static readonly HashSet PreviewableImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico" + }; + private static readonly HashSet BrowserPreviewExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".html", ".htm", ".svg" + }; + private const long MaxPreviewBytes = 1024 * 1024; private void ExplorerButton_Click(object sender, RoutedEventArgs e) => ToggleExplorer(!_explorerOpen); private void ExplorerClose_Click(object sender, RoutedEventArgs e) => ToggleExplorer(false); @@ -322,6 +351,7 @@ private void SetExplorerTab(bool changes) private void ChatRoot_SizeChanged(object sender, SizeChangedEventArgs e) { if (_explorerOpen) SizeExplorer(); + if (_previewOpen) SizePreview(); } private void SizeExplorer() @@ -336,7 +366,350 @@ private void SizeExplorer() } private const double MinExplorerWidth = 180; - private double MaxExplorerWidth() => Math.Max(MinExplorerWidth, ChatRoot.ActualWidth * 0.6); + private const double MinPreviewWidth = 280; + private double MaxExplorerWidth() => Math.Max(MinExplorerWidth, + ChatRoot.ActualWidth - (_previewOpen ? PreviewPanel.ActualWidth : 0) - 320); + private double MaxPreviewWidth() => Math.Max(MinPreviewWidth, + ChatRoot.ActualWidth - (_explorerOpen ? ExplorerPanel.ActualWidth : 0) - 320); + + private double? _previewUserWidth; + private bool _draggingPreview; + private double _previewDragStartWidth; + private double _previewDragStartX; + + private void SizePreview() + { + var width = ChatRoot.ActualWidth; + if (width <= 0) return; + var target = _previewUserWidth ?? Math.Clamp(width * 0.36, 320, 680); + PreviewPanel.Width = Math.Clamp(target, MinPreviewWidth, MaxPreviewWidth()); + } + + private void PreviewSplitter_PointerPressed(object sender, PointerRoutedEventArgs e) + { + _draggingPreview = true; + _previewDragStartWidth = PreviewPanel.ActualWidth; + _previewDragStartX = e.GetCurrentPoint(ChatRoot).Position.X; + ((UIElement)sender).CapturePointer(e.Pointer); + } + + private void PreviewSplitter_PointerMoved(object sender, PointerRoutedEventArgs e) + { + if (!_draggingPreview) return; + var delta = e.GetCurrentPoint(ChatRoot).Position.X - _previewDragStartX; + var next = Math.Clamp(_previewDragStartWidth - delta, MinPreviewWidth, MaxPreviewWidth()); + PreviewPanel.Width = next; + _previewUserWidth = next; + } + + private void PreviewSplitter_PointerReleased(object sender, PointerRoutedEventArgs e) + { + if (!_draggingPreview) return; + _draggingPreview = false; + ((UIElement)sender).ReleasePointerCapture(e.Pointer); + } + + private void TogglePreview(bool open) + { + _previewOpen = open; + if (open) + { + SizePreview(); + PreviewPanel.Visibility = Visibility.Visible; + PreviewSplitter.Visibility = Visibility.Visible; + } + else + { + PreviewPanel.Visibility = Visibility.Collapsed; + PreviewSplitter.Visibility = Visibility.Collapsed; + } + } + + private async Task OpenFilePreviewAsync(ExplorerItem item) + { + var replacingPreview = !string.Equals(_previewPath, item.FullPath, StringComparison.OrdinalIgnoreCase); + if (_previewDirty && replacingPreview) + { + if (!await ConfirmDiscardPreviewChangesAsync()) return; + ResetPreviewEditing(); + } + else if (replacingPreview) + ResetPreviewEditing(); + + _previewPath = item.FullPath; + _previewTitle = item.RelPath; + UpdatePreviewTitle(); + ToolTipService.SetToolTip(PreviewTitleText, item.FullPath); + TogglePreview(true); + PreviewText.Visibility = Visibility.Collapsed; + PreviewImageScroll.Visibility = Visibility.Collapsed; + PreviewMessage.Visibility = Visibility.Collapsed; + PreviewBrowser.Visibility = Visibility.Collapsed; + PreviewReloadButton.Visibility = Visibility.Collapsed; + _browserPreview = false; + PreviewEditButton.IsEnabled = false; + PreviewSaveButton.IsEnabled = false; + + if (!File.Exists(item.FullPath)) + { + ShowPreviewMessage("This file no longer exists."); + return; + } + + var extension = Path.GetExtension(item.FullPath); + if (BrowserPreviewExtensions.Contains(extension)) + { + await ShowBrowserPreviewAsync(item); + return; + } + if (PreviewableImageExtensions.Contains(extension)) + { + try + { + PreviewImage.Source = new BitmapImage(new Uri(item.FullPath)); + CapturePreviewFileStamp(item.FullPath); + PreviewImageScroll.Visibility = Visibility.Visible; + } + catch + { + ShowPreviewMessage("This image could not be previewed. You can still open it in its default application."); + } + return; + } + + var info = new FileInfo(item.FullPath); + if (!PreviewableTextExtensions.Contains(extension)) + { + ShowPreviewMessage("Preview is available for code, text, configuration, documentation, and common image files."); + return; + } + if (info.Length > MaxPreviewBytes) + { + ShowPreviewMessage("This file is larger than 1 MB, so it is not loaded into the preview."); + return; + } + + try + { + var text = await File.ReadAllTextAsync(item.FullPath); + if (_previewPath != item.FullPath || _shutDown) return; + _settingPreviewText = true; + PreviewText.Text = text; + _settingPreviewText = false; + _previewDirty = false; + CapturePreviewFileStamp(item.FullPath); + PreviewText.IsReadOnly = !_previewEditing; + PreviewEditButton.IsEnabled = !_previewEditing; + PreviewSaveButton.IsEnabled = false; + UpdatePreviewTitle(); + PreviewText.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + _settingPreviewText = false; + ShowPreviewMessage($"Couldn't preview this file: {ex.Message}"); + } + } + + private void ShowPreviewMessage(string text) + { + PreviewMessageText.Text = text; + PreviewMessage.Visibility = Visibility.Visible; + } + + private async Task ShowBrowserPreviewAsync(ExplorerItem item) + { + try + { + await PreviewBrowser.EnsureCoreWebView2Async(); + var core = PreviewBrowser.CoreWebView2; + if (core == null) + { + ShowPreviewMessage("The browser preview could not be initialized. You can still open this file externally."); + return; + } + + if (!_previewBrowserReady) + { + _previewBrowserReady = true; + core.Settings.AreDevToolsEnabled = true; + core.NavigationStarting += (_, args) => + { + if (Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri) && + string.Equals(uri.Host, PreviewBrowserHost, StringComparison.OrdinalIgnoreCase)) return; + args.Cancel = true; // project links cannot replace the preview with an unrelated page + if (args.IsUserInitiated && ShellOpen.Try(args.Uri) is { } ex) + _transcript.Append(_html.Warn($"Couldn't open link: {ex.Message}")); + }; + } + + var root = _controller.ProjectRootPath; + core.SetVirtualHostNameToFolderMapping( + PreviewBrowserHost, + root, + Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind.Allow); + + var relativePath = Path.GetRelativePath(root, item.FullPath).Replace('\\', '/'); + var encodedPath = string.Join('/', relativePath.Split('/').Select(Uri.EscapeDataString)); + CapturePreviewFileStamp(item.FullPath); + _browserPreview = true; + PreviewBrowser.Visibility = Visibility.Visible; + PreviewReloadButton.Visibility = Visibility.Visible; + core.Navigate($"https://{PreviewBrowserHost}/{encodedPath}"); + } + catch (Exception ex) + { + ShowPreviewMessage($"Couldn't open this browser preview: {ex.Message}"); + } + } + + private void PreviewReload_Click(object sender, RoutedEventArgs e) + { + if (_browserPreview) PreviewBrowser.CoreWebView2?.Reload(); + } + + private async void PreviewClose_Click(object sender, RoutedEventArgs e) + { + if (_previewDirty && !await ConfirmDiscardPreviewChangesAsync()) return; + ResetPreviewEditing(); + TogglePreview(false); + } + + private void PreviewEdit_Click(object sender, RoutedEventArgs e) + { + if (_previewPath == null || PreviewText.Visibility != Visibility.Visible) return; + _previewEditing = true; + PreviewText.IsReadOnly = false; + PreviewEditButton.IsEnabled = false; + PreviewText.Focus(FocusState.Programmatic); + } + + private async void PreviewSave_Click(object sender, RoutedEventArgs e) => await SavePreviewAsync(); + + private async Task SavePreviewAsync() + { + if (!_previewEditing || !_previewDirty || _previewPath == null) return; + try + { + if (File.GetLastWriteTimeUtc(_previewPath) > _previewLoadedWriteTimeUtc && + !await ConfirmOverwriteChangedFileAsync()) return; + await File.WriteAllTextAsync(_previewPath, PreviewText.Text); + CapturePreviewFileStamp(_previewPath); + _previewDirty = false; + PreviewSaveButton.IsEnabled = false; + UpdatePreviewTitle(); + _controller.NoteWorkspaceEvent($"The user edited {_previewTitle} in the Desktop file pane. Re-read it before changing it again."); + _wsTracker.MarkCapturePending(); + RefreshBranchChip(force: true); + } + catch (Exception ex) + { + ShowPreviewMessage($"Couldn't save this file: {ex.Message}"); + } + } + + private void PreviewText_TextChanged(object sender, TextChangedEventArgs e) + { + if (_settingPreviewText || !_previewEditing) return; + _previewDirty = true; + PreviewSaveButton.IsEnabled = true; + UpdatePreviewTitle(); + } + + private async void PreviewText_KeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrl = Microsoft.UI.Input.InputKeyboardSource + .GetKeyStateForCurrentThread(VirtualKey.Control) + .HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down); + if (ctrl && e.Key == VirtualKey.S) + { + e.Handled = true; + await SavePreviewAsync(); + } + } + + private void ResetPreviewEditing() + { + _previewEditing = false; + _previewDirty = false; + PreviewText.IsReadOnly = true; + PreviewEditButton.IsEnabled = PreviewText.Visibility == Visibility.Visible; + PreviewSaveButton.IsEnabled = false; + UpdatePreviewTitle(); + } + + private void UpdatePreviewTitle() => PreviewTitleText.Text = _previewDirty ? $"{_previewTitle} • unsaved" : _previewTitle; + + private async Task ConfirmDiscardPreviewChangesAsync() + { + var dialog = new ContentDialog + { + Title = "Discard unsaved edits?", + Content = $"Your unsaved changes to {_previewTitle} will be lost.", + PrimaryButtonText = "Discard edits", + CloseButtonText = "Keep editing", + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot, + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + + private async Task ConfirmOverwriteChangedFileAsync() + { + var dialog = new ContentDialog + { + Title = "File changed while you were editing", + Content = $"{_previewTitle} changed on disk, possibly by the agent. Saving now will replace that newer version with your edits.", + PrimaryButtonText = "Save my edits", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot, + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } + + private void PreviewAttach_Click(object sender, RoutedEventArgs e) + { + if (_previewPath != null) InsertFileTokens(new[] { _previewPath }); + } + + private void PreviewOpenExternal_Click(object sender, RoutedEventArgs e) + { + if (_previewPath != null && ShellOpen.Try(_previewPath) is { } ex) + _transcript.Append(_html.Warn($"Couldn't open file: {ex.Message}")); + } + + /// Agent writes can happen in several small operations. Reload once at turn end so + /// the preview shows the final file without flashing through intermediate writes. + private void RefreshOpenFilePreview(bool force = false) + { + // Never replace text the user has not saved. Once editing exists, this is the UI-level + // lock: the agent can still write its file, but it cannot silently overwrite the user's + // in-pane buffer. + if (!_previewOpen || _previewDirty || _previewPath == null || !File.Exists(_previewPath)) return; + if (!force && !HasPreviewFileChanged()) return; + if (_browserPreview) + { + CapturePreviewFileStamp(_previewPath); + PreviewBrowser.CoreWebView2?.Reload(); + return; + } + _ = OpenFilePreviewAsync(ExplorerItem.ForFile(_previewPath, _controller.ProjectRootPath)); + } + + private void CapturePreviewFileStamp(string path) + { + var info = new FileInfo(path); + _previewLoadedLength = info.Length; + _previewLoadedWriteTimeUtc = info.LastWriteTimeUtc; + } + + private bool HasPreviewFileChanged() + { + if (_previewPath == null) return false; + var info = new FileInfo(_previewPath); + return info.Length != _previewLoadedLength || info.LastWriteTimeUtc != _previewLoadedWriteTimeUtc; + } // --- splitter drag (same pointer-capture pattern as MainWindow's terminal splitter) --- @@ -766,6 +1139,8 @@ private void ExplorerTree_ItemInvoked(TreeView sender, TreeViewItemInvokedEventA // (ExplorerTree_DoubleTapped) — a stray single click must never launch an app. if (args.InvokedItem is TreeViewNode { Content: ExplorerItem { IsDirectory: true } } node) node.IsExpanded = !node.IsExpanded; + else if (args.InvokedItem is TreeViewNode { Content: ExplorerItem { IsDirectory: false } item }) + _ = OpenFilePreviewAsync(item); } private void ExplorerTree_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml b/src/MandoCode.Desktop/Controls/ChatTabView.xaml index e487cba..25be121 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml @@ -139,14 +139,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs index 4e7d46b..f763d24 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs @@ -113,6 +113,7 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm _transcript.ActivityCompleted += OnTranscriptActivityCompleted; Session.Busy.Changed += OnBusyChanged; Session.TitleChanged += OnAgentTitleChanged; + Session.PreviewTools.Requested += OnPreviewRequested; _controller.StateChanged += OnControllerStateChanged; _controller.PlanProgressChanged += OnPlanProgress; @@ -129,7 +130,11 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm // Harness events arrive on background threads; each hop marshals to the UI thread. private void OnTranscriptBlock(string html) => OnUi(() => AppendHtml(html)); private void OnTranscriptCleared() => OnUi(ClearTranscript); - private void OnTranscriptActivityCompleted() => OnUi(CompleteTranscriptActivity); + private void OnTranscriptActivityCompleted() => OnUi(() => + { + CompleteTranscriptActivity(); + RefreshOpenFilePreview(); + }); private void OnBusyChanged(bool busy, string? activity) => OnUi(() => UpdateBusy(busy, activity)); private void OnAgentTitleChanged(string _) => OnUi(UpdateHeader); private void OnControllerStateChanged() => OnUi(UpdateHeader); @@ -141,6 +146,13 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm private void OnHistoryCompacted() => _ = Task.Run(() => SessionHistoryStore.Save(Session.PersistKey, Session.Ai.ExportHistoryJson())); private void OnSnapshotOfferChanged() => OnUi(RefreshSnapshotOffer); + private void OnPreviewRequested(DesktopPreviewRequest request) => OnUi(() => + { + if (request.ForceRefresh) + RefreshOpenFilePreview(force: true); + else if (request.FullPath != null) + _ = OpenFilePreviewAsync(ExplorerItem.ForFile(request.FullPath, _controller.ProjectRootPath)); + }); private void OnUi(Action action) { @@ -343,6 +355,7 @@ public void Shutdown() _transcript.ActivityCompleted -= OnTranscriptActivityCompleted; Session.Busy.Changed -= OnBusyChanged; Session.TitleChanged -= OnAgentTitleChanged; + Session.PreviewTools.Requested -= OnPreviewRequested; _controller.StateChanged -= OnControllerStateChanged; _controller.PlanProgressChanged -= OnPlanProgress; _controller.SetupNeeded -= OnSetupNeeded; diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs index e23a7c0..286b2f6 100644 --- a/src/MandoCode.Desktop/Services/AgentSession.cs +++ b/src/MandoCode.Desktop/Services/AgentSession.cs @@ -75,6 +75,7 @@ public string Title public ApprovalPromptGate PromptGate { get; } public WinUiApprovalService Approvals { get; } public ShellRunner Shell { get; } + public DesktopPreviewTools PreviewTools { get; } public ChatController Controller { get; } /// App-wide snapshot store, shared with every other tab (see ). @@ -112,6 +113,15 @@ public AgentSession( McpGate = new McpApprovalGate(Config); Ai = new AIService(ProjectRoot, Config, Tokens, PlanHandoff, Skills, mcpManager, McpGate, spinner); + PreviewTools = new DesktopPreviewTools(ProjectRoot); + Ai.SetHostTools([ + Microsoft.Extensions.AI.AIFunctionFactory.Create( + PreviewTools.OpenDesktopPreview, + new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "open_desktop_preview" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create( + PreviewTools.RefreshDesktopPreview, + new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "refresh_desktop_preview" }), + ]); Planner = new TaskPlannerService(Ai, Config); // PersistKey is the durable tab identity. Including it in the checkpoint key prevents two // agents working in the same project from overwriting each other's unfinished plans. diff --git a/src/MandoCode.Desktop/Services/DesktopPreviewTools.cs b/src/MandoCode.Desktop/Services/DesktopPreviewTools.cs new file mode 100644 index 0000000..fb9c5ca --- /dev/null +++ b/src/MandoCode.Desktop/Services/DesktopPreviewTools.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using MandoCode.Services; + +namespace MandoCode.Desktop.Services; + +/// +/// Desktop-only agent tools for the docked preview pane. They deliberately accept only project +/// files: opening arbitrary URLs is not an agent capability, and a browser-compatible file can +/// be rendered safely through the pane's project-local virtual host. +/// +public sealed class DesktopPreviewTools +{ + private static readonly HashSet BrowserExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".html", ".htm", ".svg" + }; + + private readonly ProjectRootAccessor _projectRoot; + + public DesktopPreviewTools(ProjectRootAccessor projectRoot) => _projectRoot = projectRoot; + + /// Raised on an agent worker thread; the view marshals it to its UI thread. + public event Action? Requested; + + [Description( + "Opens a browser-compatible project file in the MandoCode Desktop preview pane. " + + "After creating or updating an HTML, HTM, or SVG page, call this when the user would " + + "benefit from seeing the result. Use a project-relative path only. This does not open " + + "external websites or run a development server.")] + public string OpenDesktopPreview( + [Description("Project-relative path to an existing .html, .htm, or .svg page to show in the Desktop preview pane.")] + string relativePath) + { + if (!TryResolveBrowserFile(relativePath, out var fullPath, out var error)) return error; + + Requested?.Invoke(DesktopPreviewRequest.Open(fullPath)); + return $"Opened {Path.GetRelativePath(_projectRoot.ProjectRoot, fullPath)} in the Desktop preview pane."; + } + + [Description( + "Refreshes the page currently open in the MandoCode Desktop preview pane. Call this " + + "after finishing changes that affect an already open webpage, including its CSS, " + + "JavaScript, or other local assets. Do not call it when no preview is open.")] + public string RefreshDesktopPreview() + { + Requested?.Invoke(DesktopPreviewRequest.Refresh()); + return "Requested a refresh of the current Desktop preview."; + } + + private bool TryResolveBrowserFile(string relativePath, out string fullPath, out string message) + { + fullPath = ""; + if (string.IsNullOrWhiteSpace(relativePath)) + { + message = "A project-relative HTML, HTM, or SVG path is required."; + return false; + } + if (Path.IsPathRooted(relativePath)) + { + message = "Use a project-relative path, not an absolute path."; + return false; + } + + var root = Path.GetFullPath(_projectRoot.ProjectRoot); + var candidate = Path.GetFullPath(Path.Combine(root, relativePath)); + var rootWithSeparator = Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) + { + message = "The requested preview file must stay inside the current project."; + return false; + } + if (!File.Exists(candidate)) + { + message = $"The file '{relativePath}' does not exist yet. Create it before opening a preview."; + return false; + } + if (!BrowserExtensions.Contains(Path.GetExtension(candidate))) + { + message = "Desktop webpage preview supports .html, .htm, and .svg files."; + return false; + } + + fullPath = candidate; + message = ""; + return true; + } +} + +public sealed record DesktopPreviewRequest(string? FullPath, bool ForceRefresh) +{ + public static DesktopPreviewRequest Open(string fullPath) => new(fullPath, ForceRefresh: false); + public static DesktopPreviewRequest Refresh() => new(null, ForceRefresh: true); +}